Compare commits
17
Commits
No files matched your search
-11
@@ -39,14 +39,3 @@ yarn-error.log*
|
||||
# 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
|
||||
@@ -0,0 +1,114 @@
|
||||
## 📋 Análise
|
||||
**Solicitação**: Corrigir falhas de conexão do browser headless e o erro de compilação `Send`, tornar o scraper resiliente com retry e ampliar resultados de busca.
|
||||
**Objetivo real**: Garantir estabilidade contínua do backend de scraping, mesmo quando o Chrome headless cai, evitando violações de `Send` e melhorando a cobertura de fontes.
|
||||
|
||||
## 🔍 Contexto Identificado
|
||||
### Arquivos Examinados
|
||||
- `src-tauri/src/lib.rs:1176–1193` get_or_create_browser: cria/reutiliza `Browser` singleton.
|
||||
- `src-tauri/src/lib.rs:1247–1277` search_web_metadata: comando Tauri que exige `Future + Send`.
|
||||
- `src-tauri/src/web_scraper.rs:136–240` search_duckduckgo_metadata: parsing HTML, previously segurava `Html` atravessando `await`.
|
||||
- `src-tauri/src/web_scraper.rs:469–538` search_and_scrape_with_config: coordena scraping paralelo e coleta resultados.
|
||||
- `src-tauri/src/web_scraper.rs:605–736` fetch_and_convert_sync: cria aba, navega, extrai HTML e usa Readability.
|
||||
- `src-tauri/src/lib.rs:1306–1399` force_kill_browser: encerra processos Chrome headless.
|
||||
|
||||
### Padrões Detectados
|
||||
- **Singleton Browser** via `State<BrowserState>` com `Arc<Browser>`.
|
||||
- **Concorrência controlada** com `Semaphore` e `tokio::spawn_blocking` para operações de Chrome.
|
||||
- **Fallbacks**: limpeza de URL, filtros anti-ads, Readability + fallback por parágrafos.
|
||||
|
||||
### Dependências Mapeadas
|
||||
```
|
||||
web_scraper (modificado)
|
||||
├─ headless_chrome::Browser/Tab
|
||||
├─ reqwest (HTTP)
|
||||
├─ scraper (Html, Selector) [não Send]
|
||||
└─ readability, html2text
|
||||
lib (modificado)
|
||||
└─ tauri commands (Future + Send obrigatório)
|
||||
```
|
||||
|
||||
## 🎯 Abordagens Viáveis
|
||||
|
||||
### Abordagem A: Verificação ativa de Browser + Retry cooperativo
|
||||
**Conceito**: Validar liveness do `Browser` antes de uso; se cair durante scraping, recriar e repetir uma passada de URLs com limite de tentativas.
|
||||
```
|
||||
request → get_or_create_browser (is_alive?) → spawn scraping
|
||||
↳ erro conn fechada → reset + recreate → retry restante (1x)
|
||||
```
|
||||
**Vantagens**: ✓ Simples de integrar, ✓ Minimiza impacto, ✓ Boa resiliência
|
||||
**Desvantagens**: ✗ Retry global pode repetir algumas URLs
|
||||
**Complexidade**: O(n) | Tempo: 3–4h | Risco: Médio
|
||||
|
||||
### Abordagem B: Pool/Auto-heal por task
|
||||
**Conceito**: Cada task recupera o browser ao falhar e re-executa só sua URL.
|
||||
```
|
||||
spawn_blocking(url) → new_tab → erro conn → sinaliza reset → reobtem browser → reprocessa url
|
||||
```
|
||||
**Vantagens**: ✓ Isolamento por URL
|
||||
**Desvantagens**: ✗ Coordenação de estado entre threads, ✗ Maior complexidade
|
||||
**Complexidade**: O(n) | Tempo: 5–6h | Risco: Médio-Alto
|
||||
|
||||
## 🏆 Recomendação
|
||||
**Abordagem A** porque:
|
||||
- Alinha com o padrão singleton já existente.
|
||||
- Menor acoplamento entre tarefas e estado global.
|
||||
- Implementação direta com menor risco de condições de corrida.
|
||||
|
||||
## ⚠️ Riscos & Mitigações
|
||||
| Risco | Prob | Impacto | Mitigação |
|
||||
|-------|------|---------|-----------|
|
||||
| Loop de retry infinito | Baixa | Médio | Limitar a 1–2 tentativas, backoff curto |
|
||||
| Recriação concorrente do Browser | Média | Médio | Guardar lock no `BrowserState` e checar antes de recriar |
|
||||
| Demasiadas abas simultâneas | Média | Médio | Reduzir `max_concurrent_tabs` ao detectar falha |
|
||||
| DDG bloqueios 429 | Média | Baixo | Rotação User-Agent e backoff |
|
||||
|
||||
## 📝 Plano de Execução
|
||||
1. Ajustes `Send` (consolidação)
|
||||
- Isolar parsing `scraper::Html` em escopo síncrono que retorna `Vec<SearchResultMetadata>` (já aplicado em parte na função; revisar para todos os caminhos).
|
||||
- Garantir que nenhum `Html`/`Selector` vive além do `await` nos métodos `search_*`.
|
||||
- Verificar pontos: `src-tauri/src/web_scraper.rs:136–240` e demais que usem `Html`.
|
||||
|
||||
2. Browser Resurrection
|
||||
- Adicionar `fn is_browser_alive(browser: &Browser) -> bool` em `web_scraper.rs` ou `lib.rs` (ex.: tentar `new_tab()` e descartar; ou `get_tabs()`; se falhar → false).
|
||||
- Alterar `get_or_create_browser` (`src-tauri/src/lib.rs:1176–1193`) para: se `Some(browser)` e `!is_alive(browser)` → `reset` e `create_browser()`.
|
||||
- Em `force_kill_browser` (`src-tauri/src/lib.rs:1306–1399`): após kill, setar `BrowserState` para `None` via lock (ou orientar UI a chamar `reset_browser` antes do kill), garantindo recriação no próximo uso.
|
||||
|
||||
3. Retry resiliente no Scraper
|
||||
- Envolver `fetch_and_convert_sync` com camada de retry (máx. 2 tentativas) na orquestração async (`search_and_scrape_with_config` em `src-tauri/src/web_scraper.rs:469–538`).
|
||||
- Se erro contiver "underlying connection is closed"/timeout global do browser:
|
||||
- Executar `reset_browser` e `get_or_create_browser` para novo `Arc<Browser)`.
|
||||
- Reprocessar as URLs que falharam apenas uma vez com o novo browser.
|
||||
- Reduzir temporariamente `max_concurrent_tabs` para 3 ao detectar queda, evitando sobrecarga imediata.
|
||||
|
||||
4. Paginação no DuckDuckGo
|
||||
- Em `search_duckduckgo` (`src-tauri/src/web_scraper.rs:78–133`): suportar paginação via parâmetro `s=<offset>` até atingir `limit` (30–50).
|
||||
- Extrair links por página, deduplicar, respeitar `excluded_domains`/ads.
|
||||
|
||||
5. Telemetria e Logs
|
||||
- Logar eventos de recreação de browser, tentativas de retry e motivos.
|
||||
- Contadores: quedas de browser, tentativas, tempo médio por URL.
|
||||
|
||||
6. Testes/Validação
|
||||
- Caso 1: Forçar `force_kill_browser` durante scraping → validar que a próxima chamada recria o browser e conclui.
|
||||
- Caso 2: Simular DDG com >= 40 resultados → paginar e coletar sem violar `Send`.
|
||||
- Caso 3: Stress com `max_concurrent_tabs=5` → se cair, reduzir e recuperar.
|
||||
|
||||
## Key Changes (Resumo de Implementação)
|
||||
- `lib.rs:1176–1193`: `get_or_create_browser` verifica liveness; recria se morto.
|
||||
- `lib.rs:1306–1399`: sincronia com `reset_browser` ao matar processos.
|
||||
- `web_scraper.rs:469–538`: retry global (1x) e recriação de browser ao detectar conexão fechada; ajuste dinamicamente `Semaphore` na segunda passada.
|
||||
- `web_scraper.rs:78–133`: paginação DDG com `s=` offset.
|
||||
- `web_scraper.rs:136–240`: parsing isolado; garantir que `Html` é dropado antes do `await`.
|
||||
|
||||
## Entregáveis
|
||||
- Código robusto em `web_scraper.rs` e `lib.rs` com:
|
||||
- Correção total do `Send`.
|
||||
- Resurreição automática do browser.
|
||||
- Retry controlado por erro de conexão.
|
||||
- Paginação para 30–50 resultados.
|
||||
- Logs melhorados e validação manual automática.
|
||||
|
||||
## ❓ Aguardando Aprovação
|
||||
- [ ] Posso prosseguir com as mudanças propostas?
|
||||
- [ ] Preferências de `max_concurrent_tabs` e `limit` padrão (sugestão: 5 e 40)?
|
||||
- [ ] Deseja manter `force_kill_browser` como utilitário avançado, com alerta na UI para reiniciar o scraper?
|
||||
@@ -0,0 +1,309 @@
|
||||
# Guia de Implementação: System Tray, Background Mode e Task Scheduler
|
||||
|
||||
## Status da Implementação
|
||||
|
||||
### ✅ Concluído
|
||||
|
||||
1. **Estrutura de Tasks** (`src-tauri/src/scheduler.rs`)
|
||||
- `SentinelTask` com suporte a múltiplos tipos de ações
|
||||
- Persistência em `tasks.json`
|
||||
- Gerenciamento de estado thread-safe
|
||||
|
||||
2. **Cliente Ollama Headless** (`src-tauri/src/ollama_client.rs`)
|
||||
- Comunicação direta com Ollama via `reqwest`
|
||||
- Suporte a streaming
|
||||
- Verificação de conexão
|
||||
|
||||
3. **Executor de Tasks** (`src-tauri/src/task_executor.rs`)
|
||||
- Execução de `SearchAndSummarize`
|
||||
- Execução de `JustPing`
|
||||
- Execução de `CustomPrompt`
|
||||
- Integração com file locks para escrita segura
|
||||
|
||||
4. **Scheduler Loop** (`src-tauri/src/scheduler_loop.rs`)
|
||||
- Integração com `tokio-cron-scheduler`
|
||||
- Recarregamento dinâmico de tasks
|
||||
- Execução assíncrona em background
|
||||
|
||||
### 🔨 Pendente (Próximos Passos)
|
||||
|
||||
#### 1. Comandos Tauri para CRUD de Tasks
|
||||
|
||||
Adicione ao `src-tauri/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
use scheduler::{SchedulerService, SchedulerState, SentinelTask, TaskAction};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[command]
|
||||
async fn create_task(
|
||||
scheduler: State<'_, SchedulerState>,
|
||||
label: String,
|
||||
cron_schedule: String,
|
||||
action: TaskAction,
|
||||
) -> Result<String, String> {
|
||||
let task = SentinelTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
label,
|
||||
cron_schedule,
|
||||
action,
|
||||
enabled: true,
|
||||
last_run: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
let mut sched = scheduler.lock().await;
|
||||
sched.upsert_task(task.clone())?;
|
||||
Ok(task.id)
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn list_tasks(
|
||||
scheduler: State<'_, SchedulerState>,
|
||||
) -> Result<Vec<SentinelTask>, String> {
|
||||
let sched = scheduler.lock().await;
|
||||
Ok(sched.list_tasks())
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn update_task(
|
||||
scheduler: State<'_, SchedulerState>,
|
||||
task: SentinelTask,
|
||||
) -> Result<(), String> {
|
||||
let mut sched = scheduler.lock().await;
|
||||
let mut updated = task;
|
||||
updated.updated_at = Utc::now();
|
||||
sched.upsert_task(updated)
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn delete_task(
|
||||
scheduler: State<'_, SchedulerState>,
|
||||
id: String,
|
||||
) -> Result<(), String> {
|
||||
let mut sched = scheduler.lock().await;
|
||||
sched.remove_task(&id)
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn toggle_task(
|
||||
scheduler: State<'_, SchedulerState>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut sched = scheduler.lock().await;
|
||||
if let Some(mut task) = sched.get_task(&id).cloned() {
|
||||
task.enabled = enabled;
|
||||
task.updated_at = Utc::now();
|
||||
sched.upsert_task(task)
|
||||
} else {
|
||||
Err("Task not found".to_string())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. System Tray Setup
|
||||
|
||||
No `src-tauri/src/lib.rs`, modifique o `.setup()`:
|
||||
|
||||
```rust
|
||||
use tauri::{Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem};
|
||||
|
||||
.setup(|app| {
|
||||
// ... código existente ...
|
||||
|
||||
// Criar System Tray
|
||||
let tray_menu = SystemTrayMenu::new()
|
||||
.add_item(SystemTrayMenuItem::with_id("show", "Mostrar OllaHub", true, None))
|
||||
.add_item(SystemTrayMenuItem::with_id("pause", "Pausar Tasks", true, None))
|
||||
.add_native_item(SystemTrayMenuItem::Separator)
|
||||
.add_item(SystemTrayMenuItem::with_id("quit", "Sair", true, None));
|
||||
|
||||
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||
|
||||
app.handle().system_tray(system_tray)?;
|
||||
|
||||
// Handler de eventos do Tray
|
||||
app.handle().on_system_tray_event(|app, event| {
|
||||
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
|
||||
match id.as_str() {
|
||||
"show" => {
|
||||
if let Some(window) = app.get_window("main") {
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
}
|
||||
}
|
||||
"pause" => {
|
||||
// Implementar lógica de pausar tasks
|
||||
log::info!("Pausar tasks - implementar");
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Modificar comportamento de fechar janela
|
||||
if let Some(window) = app.get_window("main") {
|
||||
window.on_window_event(|event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
// Ocultar ao invés de fechar
|
||||
if let Some(window) = event.window() {
|
||||
window.hide().unwrap();
|
||||
}
|
||||
api.prevent_close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
```
|
||||
|
||||
#### 3. Inicializar Scheduler no Setup
|
||||
|
||||
No mesmo `.setup()`, após criar o scheduler:
|
||||
|
||||
```rust
|
||||
// Inicializar scheduler
|
||||
let scheduler_service = SchedulerService::new(app.handle().clone())?;
|
||||
let scheduler_state: SchedulerState = Arc::new(Mutex::new(scheduler_service));
|
||||
let browser_state: BrowserState = app.state();
|
||||
|
||||
// Iniciar loop do scheduler em background
|
||||
let app_handle = app.handle().clone();
|
||||
let scheduler_clone = scheduler_state.clone();
|
||||
let browser_clone = browser_state.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = scheduler_loop::start_scheduler_loop(
|
||||
app_handle,
|
||||
scheduler_clone,
|
||||
browser_clone,
|
||||
None, // Ollama URL - pode vir do settings
|
||||
).await {
|
||||
log::error!("Erro ao iniciar scheduler: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Adicionar ao manage
|
||||
.manage(scheduler_state)
|
||||
```
|
||||
|
||||
#### 4. Adicionar Comandos ao Invoke Handler
|
||||
|
||||
```rust
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// ... comandos existentes ...
|
||||
create_task,
|
||||
list_tasks,
|
||||
update_task,
|
||||
delete_task,
|
||||
toggle_task,
|
||||
])
|
||||
```
|
||||
|
||||
#### 5. Interface React (app/tasks/page.tsx)
|
||||
|
||||
Crie uma página básica para gerenciar tasks:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
label: string;
|
||||
cron_schedule: string;
|
||||
action: any;
|
||||
enabled: boolean;
|
||||
last_run: string | null;
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, []);
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const loaded = await invoke<Task[]>('list_tasks');
|
||||
setTasks(loaded);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar tasks:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTask = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
await invoke('toggle_task', { id, enabled: !enabled });
|
||||
await loadTasks();
|
||||
} catch (error) {
|
||||
console.error('Erro ao alternar task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Tarefas Agendadas</h1>
|
||||
<div className="space-y-4">
|
||||
{tasks.map(task => (
|
||||
<Card key={task.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{task.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Cron: {task.cron_schedule}</p>
|
||||
<p>Status: {task.enabled ? 'Ativa' : 'Pausada'}</p>
|
||||
<Button onClick={() => toggleTask(task.id, task.enabled)}>
|
||||
{task.enabled ? 'Pausar' : 'Ativar'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Dependências Adicionais Necessárias
|
||||
|
||||
No `Cargo.toml`, já foram adicionadas:
|
||||
- `tokio-cron-scheduler = "0.9"`
|
||||
- `futures-util = "0.3"`
|
||||
- `tauri-plugin-notification = "2"`
|
||||
|
||||
**Nota para Linux:** Para System Tray funcionar, pode ser necessário:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install libappindicator3-1
|
||||
|
||||
# Arch/Manjaro
|
||||
sudo pacman -S libappindicator-gtk3
|
||||
```
|
||||
|
||||
## Próximos Passos
|
||||
|
||||
1. Compilar e testar: `cd src-tauri && cargo build`
|
||||
2. Corrigir erros de compilação
|
||||
3. Testar criação de task via frontend
|
||||
4. Verificar se scheduler executa corretamente
|
||||
5. Testar System Tray em diferentes plataformas
|
||||
|
||||
## Notas Importantes
|
||||
|
||||
- **File Locking**: Já implementado - tasks usam o mesmo sistema de locks que salvar sessões
|
||||
- **Concorrência**: Scheduler roda em thread separada, não bloqueia UI
|
||||
- **Persistência**: Tasks são salvas em `app_data_dir/tasks.json`
|
||||
- **Notificações**: Usa `tauri-plugin-notification` para notificações nativas
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1312
File diff suppressed because it is too large.
Load diff
+368
-127
@@ -1,164 +1,405 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #0b0c0f;
|
||||
--accent: #2563eb; /* blue-600 */
|
||||
--accent-foreground: #ffffff;
|
||||
--border: #e5e7ef;
|
||||
--surface: #f6f7fb;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant hc (&:is([data-high-contrast=true] *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-accent: var(--accent);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--font-sans: var(--font-inter);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-message-user: var(--message-user);
|
||||
--color-message-assistant: var(--message-assistant);
|
||||
--color-message-highlight: var(--message-highlight);
|
||||
--color-message-selected: var(--message-selected);
|
||||
--color-message-high-contrast: var(--message-high-contrast);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--space-1: 8px;
|
||||
--space-2: 16px;
|
||||
--space-3: 24px;
|
||||
--space-4: 32px;
|
||||
--space-5: 40px;
|
||||
--space-6: 48px;
|
||||
--space-7: 56px;
|
||||
--space-8: 64px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
--message-user: oklch(0.965 0.02 245);
|
||||
--message-assistant: oklch(0.972 0.012 300);
|
||||
--message-highlight: oklch(0.94 0.02 250);
|
||||
--message-selected: oklch(0.92 0.02 285);
|
||||
--message-high-contrast: oklch(0.99 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0b0c0f;
|
||||
--foreground: #e6e8ef;
|
||||
--accent: #3b82f6; /* blue-500 */
|
||||
--accent-foreground: #0b0c0f;
|
||||
--border: #2a2f3a;
|
||||
--surface: #12141a;
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--message-user: oklch(0.23 0.02 245);
|
||||
--message-assistant: oklch(0.24 0.015 300);
|
||||
--message-highlight: oklch(0.34 0.02 250);
|
||||
--message-selected: oklch(0.25 0.02 285);
|
||||
--message-high-contrast: oklch(0.18 0.005 286);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-inter), ui-sans-serif, system-ui;
|
||||
:root[data-high-contrast="true"] {
|
||||
--message-user: oklch(0.9 0 0);
|
||||
--message-assistant: oklch(0.9 0 0);
|
||||
--message-highlight: oklch(0.8 0 0);
|
||||
--message-selected: oklch(0.7 0 0);
|
||||
--message-high-contrast: oklch(0.99 0 0);
|
||||
}
|
||||
|
||||
/* Custom scrollbar para o seletor de modelos */
|
||||
.custom-scrollbar {
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@apply border-border outline-ring/50;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbar Styles - Padronizado para toda interface */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-color: hsl(var(--primary)) hsl(var(--muted));
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive layout adjustments */
|
||||
@media (max-width: 650px) {
|
||||
/* Sidebar compact mode - hide labels, show only icons */
|
||||
.sidebar-compact .sidebar-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-compact .sidebar-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure proper spacing and padding at all resolutions */
|
||||
@media (min-width: 320px) and (max-width: 2560px) {
|
||||
/* Prevent layout breaking at extreme sizes */
|
||||
.chat-container {
|
||||
min-width: 320px;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
padding-left: clamp(12px, 4vw, 24px);
|
||||
padding-right: clamp(12px, 4vw, 24px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent text overflow in cards and messages */
|
||||
.truncate-with-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Ensure cards never stick to edges */
|
||||
.card-container {
|
||||
padding: clamp(8px, 2vw, 16px);
|
||||
}
|
||||
|
||||
/* Sidebar chat item - prevent overflow and ensure proper truncation */
|
||||
.sidebar-chat-item {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-chat-item-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-chat-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-chat-item-title {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-chat-item-date {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive font sizes */
|
||||
@media (max-width: 640px) {
|
||||
.responsive-text {
|
||||
font-size: clamp(14px, 4vw, 16px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Model name truncation - ensure long model names don't break layout */
|
||||
.model-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 70vw;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.model-name {
|
||||
max-width: 54vw;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Select value truncation */
|
||||
[data-radix-select-trigger] span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Ensure SelectItems also truncate properly */
|
||||
[data-radix-select-item] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-radix-select-item] span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 70vw;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Scrollbar específico para lista de chats - mais fino e discreto */
|
||||
.sidebar-chat-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
.sidebar-chat-list::-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;
|
||||
.sidebar-chat-list::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
/* MCP Marketplace Styles */
|
||||
.mcp-card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
.sidebar-chat-list::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--primary) / 0.6);
|
||||
}
|
||||
|
||||
.mcp-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
/* Firefox scrollbar para lista de chats */
|
||||
.sidebar-chat-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||
}
|
||||
|
||||
.mcp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
/* Minimalista scrollbar para sources */
|
||||
.sources-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.2) transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mcp-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.sources-scrollbar::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.sources-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sources-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
border-radius: 2px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.sources-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.35);
|
||||
}
|
||||
|
||||
/* Ocultar scrollbar quando não há overflow */
|
||||
.sources-scrollbar:not(:hover)::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Reasoning Chain animations */
|
||||
@keyframes reasoning-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.mcp-modal {
|
||||
backdrop-filter: blur(8px);
|
||||
.reasoning-step {
|
||||
animation: reasoning-fade-in 0.3s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
/* Staggered reveal para steps */
|
||||
.reasoning-step:nth-child(1) { animation-delay: 0ms; }
|
||||
.reasoning-step:nth-child(2) { animation-delay: 100ms; }
|
||||
.reasoning-step:nth-child(3) { animation-delay: 200ms; }
|
||||
.reasoning-step:nth-child(4) { animation-delay: 300ms; }
|
||||
.reasoning-step:nth-child(5) { animation-delay: 400ms; }
|
||||
.reasoning-step:nth-child(6) { animation-delay: 500ms; }
|
||||
.reasoning-step:nth-child(7) { animation-delay: 600ms; }
|
||||
.reasoning-step:nth-child(8) { animation-delay: 700ms; }
|
||||
.reasoning-step:nth-child(n+9) { animation-delay: 800ms; }
|
||||
+25
-8
@@ -1,13 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "../components/theme-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "OllaHub",
|
||||
description: "Interface moderna para Ollama",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -16,9 +24,18 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
<html lang="pt-BR" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+87
-17
@@ -1,21 +1,91 @@
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { ChatContainer } from "@/components/chat/ChatContainer";
|
||||
import { ClientHome } from "@/components/ClientHome";
|
||||
'use client';
|
||||
|
||||
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: [] };
|
||||
import { useOllamaCheck } from "@/hooks/use-ollama-check";
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { InstallModal } from "@/components/modals/install-modal";
|
||||
import { StoppedCard } from "@/components/landing/stopped-card";
|
||||
import { WelcomeNotification } from "@/components/notifications/welcome-notification";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Home() {
|
||||
const { status, check } = useOllamaCheck();
|
||||
const router = useRouter();
|
||||
|
||||
// Tentar iniciar Ollama automaticamente quando o componente montar
|
||||
useEffect(() => {
|
||||
const tryAutoStart = async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke('auto_start_ollama');
|
||||
// Aguardar um pouco e verificar novamente
|
||||
setTimeout(() => {
|
||||
check();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Erro ao tentar iniciar Ollama automaticamente:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Só tentar se estiver instalado mas não rodando
|
||||
if (status === 'installed_stopped') {
|
||||
tryAutoStart();
|
||||
}
|
||||
}, [status, check]);
|
||||
|
||||
const handleStart = () => {
|
||||
const setupComplete = localStorage.getItem("ollahub_setup_complete");
|
||||
if (setupComplete) {
|
||||
router.push("/chat");
|
||||
} else {
|
||||
router.push("/setup");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'checking') {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-background">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-2 text-muted-foreground">Verificando sistema...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
const { offline, models } = await fetchModels();
|
||||
return <ClientHome offline={offline} models={models} />;
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
|
||||
{/* Notificação de boas-vindas */}
|
||||
<WelcomeNotification />
|
||||
|
||||
{/* Layout for Running State */}
|
||||
{status === 'running' && (
|
||||
<Hero onStart={handleStart} />
|
||||
)}
|
||||
|
||||
{/* Layout for Stopped State */}
|
||||
{status === 'installed_stopped' && (
|
||||
<div className="flex flex-col items-center gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tighter">OllaHub</h1>
|
||||
<p className="text-muted-foreground">Detectamos o Ollama, mas ele não está rodando.</p>
|
||||
</div>
|
||||
<StoppedCard onCheckAgain={check} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layout for Not Installed State (Background + Modal) */}
|
||||
{status === 'not_installed' && (
|
||||
<>
|
||||
<div className="opacity-20 pointer-events-none blur-sm">
|
||||
<Hero onStart={() => {}} />
|
||||
</div>
|
||||
{/* Hero is shown in background, modal is open */}
|
||||
<InstallModal
|
||||
open={true}
|
||||
onCheckAgain={check}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useHardware } from "@/hooks/use-hardware";
|
||||
import { HardwareScan } from "@/components/setup/hardware-scan";
|
||||
import { GpuSelector } from "@/components/setup/gpu-selector";
|
||||
import { ModelSelector } from "@/components/setup/model-selector";
|
||||
import { getRecommendation } from "@/lib/recommendation";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function SetupPage() {
|
||||
const { specs, loading, gpus } = useHardware();
|
||||
const router = useRouter();
|
||||
|
||||
const handleComplete = () => {
|
||||
// Save setup state if needed
|
||||
localStorage.setItem("ollahub_setup_complete", "true");
|
||||
router.push("/chat");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground">
|
||||
<div className="w-full max-w-2xl space-y-8 animate-in fade-in duration-700">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
|
||||
<p className="text-muted-foreground">Vamos preparar o ambiente ideal para você.</p>
|
||||
</div>
|
||||
|
||||
<HardwareScan specs={specs} loading={loading} />
|
||||
|
||||
{!loading && specs && gpus.length > 0 && (
|
||||
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-200 fill-mode-backwards">
|
||||
<GpuSelector gpus={gpus} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && specs && (
|
||||
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-300 fill-mode-backwards">
|
||||
<ModelSelector
|
||||
recommendation={getRecommendation(specs)}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Plus, Trash2, Clock, Play, Pause, CheckCircle2, XCircle, X, ArrowLeft } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { TitleBar } from '@/components/titlebar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
label: string;
|
||||
cron_schedule: string;
|
||||
action: {
|
||||
type: 'search_and_summarize' | 'just_ping' | 'custom_prompt';
|
||||
query?: string;
|
||||
model?: string;
|
||||
max_results?: number;
|
||||
message?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
enabled: boolean;
|
||||
last_run: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const router = useRouter();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, []);
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const loaded = await invoke<Task[]>('list_tasks');
|
||||
setTasks(loaded);
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTask = async (id: string, currentEnabled: boolean) => {
|
||||
try {
|
||||
await invoke('toggle_task', { id, enabled: !currentEnabled });
|
||||
await loadTasks();
|
||||
} catch (error) {
|
||||
console.error('Erro ao alternar task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async (id: string) => {
|
||||
if (!confirm('Tem certeza que deseja excluir esta task?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke('delete_task', { id });
|
||||
await loadTasks();
|
||||
} catch (error) {
|
||||
console.error('Erro ao excluir task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCron = (cron: string) => {
|
||||
// Formatação básica de cron para exibição
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length === 5) {
|
||||
const [min, hour, day, month, weekday] = parts;
|
||||
if (min === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||
return `Todo dia às ${hour}:00`;
|
||||
}
|
||||
if (min === '0' && hour === '8' && day === '*' && month === '*' && weekday === '*') {
|
||||
return 'Todo dia às 8:00';
|
||||
}
|
||||
}
|
||||
return cron;
|
||||
};
|
||||
|
||||
const formatLastRun = (lastRun: string | null) => {
|
||||
if (!lastRun) return 'Nunca';
|
||||
try {
|
||||
const date = new Date(lastRun);
|
||||
return date.toLocaleString('pt-BR');
|
||||
} catch {
|
||||
return lastRun;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||
<TitleBar />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden">
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold truncate">Tarefas Agendadas</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||
Gerencie tarefas que executam automaticamente em segundo plano
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push('/chat')}
|
||||
className="rounded-lg shrink-0"
|
||||
title="Voltar para o chat"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Carregando tasks...
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Nenhuma task agendada ainda.
|
||||
</p>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="lg" className="w-full max-w-md">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Criar Primeira Task
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar Nova Task</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure uma tarefa para executar automaticamente
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskFormDialog
|
||||
onSuccess={() => {
|
||||
setIsDialogOpen(false);
|
||||
loadTasks();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="lg" className="w-full">
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Nova Task
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Criar Nova Task</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure uma tarefa para executar automaticamente
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskFormDialog
|
||||
onSuccess={() => {
|
||||
setIsDialogOpen(false);
|
||||
loadTasks();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{tasks.map(task => (
|
||||
<Card key={task.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{task.label}
|
||||
{task.enabled ? (
|
||||
<Badge variant="default" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
Ativa
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Pausada
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatCron(task.cron_schedule)}
|
||||
</span>
|
||||
<span>Última execução: {formatLastRun(task.last_run)}</span>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`toggle-${task.id}`} className="text-sm">
|
||||
{task.enabled ? 'Ativa' : 'Pausada'}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`toggle-${task.id}`}
|
||||
checked={task.enabled}
|
||||
onCheckedChange={() => toggleTask(task.id, task.enabled)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteTask(task.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p><strong>Tipo:</strong> {
|
||||
task.action.type === 'search_and_summarize' ? 'Pesquisar e Resumir' :
|
||||
task.action.type === 'just_ping' ? 'Notificação' :
|
||||
'Prompt Customizado'
|
||||
}</p>
|
||||
{task.action.query && (
|
||||
<p><strong>Query:</strong> {task.action.query}</p>
|
||||
)}
|
||||
{task.action.model && (
|
||||
<p><strong>Modelo:</strong> {task.action.model}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskFormDialog({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [label, setLabel] = useState('');
|
||||
const [scheduleType, setScheduleType] = useState<'daily' | 'weekly' | 'hourly' | 'custom'>('daily');
|
||||
const [hour, setHour] = useState('8');
|
||||
const [minute, setMinute] = useState('0');
|
||||
const [intervalHours, setIntervalHours] = useState('1');
|
||||
const [customCron, setCustomCron] = useState('');
|
||||
const [taskType, setTaskType] = useState<'search_and_summarize' | 'just_ping' | 'custom_prompt'>('search_and_summarize');
|
||||
const [query, setQuery] = useState('');
|
||||
const [model, setModel] = useState('llama3.2');
|
||||
const [maxResults, setMaxResults] = useState(3);
|
||||
const [message, setMessage] = useState('');
|
||||
const [customPrompt, setCustomPrompt] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const generateCron = (): string => {
|
||||
if (scheduleType === 'custom') {
|
||||
return customCron;
|
||||
}
|
||||
if (scheduleType === 'hourly') {
|
||||
return `0 */${intervalHours} * * *`;
|
||||
}
|
||||
if (scheduleType === 'daily') {
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
// Weekly (every Monday at hour:minute)
|
||||
return `${minute} ${hour} * * 1`;
|
||||
};
|
||||
|
||||
const getCronPreview = (): string => {
|
||||
const cron = generateCron();
|
||||
if (scheduleType === 'hourly') {
|
||||
return `A cada ${intervalHours} hora(s)`;
|
||||
}
|
||||
if (scheduleType === 'daily') {
|
||||
return `Todo dia às ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
||||
}
|
||||
if (scheduleType === 'weekly') {
|
||||
return `Toda segunda-feira às ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
||||
}
|
||||
return cron;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
let action: any;
|
||||
|
||||
if (taskType === 'search_and_summarize') {
|
||||
action = {
|
||||
type: 'search_and_summarize',
|
||||
query,
|
||||
model,
|
||||
max_results: maxResults,
|
||||
};
|
||||
} else if (taskType === 'just_ping') {
|
||||
action = {
|
||||
type: 'just_ping',
|
||||
message,
|
||||
};
|
||||
} else {
|
||||
action = {
|
||||
type: 'custom_prompt',
|
||||
prompt: customPrompt,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
await invoke('create_task', {
|
||||
label,
|
||||
cronSchedule: generateCron(),
|
||||
action,
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar task:', error);
|
||||
alert('Erro ao criar task. Verifique o console para mais detalhes.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="label">Nome da Task</Label>
|
||||
<Input
|
||||
id="label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Ex: Resumo de Notícias Diário"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="schedule-type">Frequência</Label>
|
||||
<Select value={scheduleType} onValueChange={(v: any) => setScheduleType(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Diário</SelectItem>
|
||||
<SelectItem value="weekly">Semanal</SelectItem>
|
||||
<SelectItem value="hourly">A cada X horas</SelectItem>
|
||||
<SelectItem value="custom">Cron Customizado (Avançado)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Cron Preview */}
|
||||
<div className="bg-muted p-3 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Agendamento:</span>
|
||||
<span className="text-sm text-muted-foreground">{getCronPreview()}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground font-mono">
|
||||
Cron: {generateCron()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleType === 'custom' ? (
|
||||
<div>
|
||||
<Label htmlFor="cron">Expressão Cron</Label>
|
||||
<Input
|
||||
id="cron"
|
||||
value={customCron}
|
||||
onChange={(e) => setCustomCron(e.target.value)}
|
||||
placeholder="0 8 * * * (todo dia às 8h)"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Formato: minuto hora dia mês dia-da-semana (ex: 0 8 * * * = todo dia às 8h)
|
||||
</p>
|
||||
</div>
|
||||
) : scheduleType === 'hourly' ? (
|
||||
<div>
|
||||
<Label htmlFor="interval-hours">Intervalo (horas)</Label>
|
||||
<Input
|
||||
id="interval-hours"
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
value={intervalHours}
|
||||
onChange={(e) => setIntervalHours(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
A task será executada a cada {intervalHours} hora(s)
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="hour">Hora (0-23)</Label>
|
||||
<Input
|
||||
id="hour"
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value={hour}
|
||||
onChange={(e) => setHour(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="minute">Minuto (0-59)</Label>
|
||||
<Input
|
||||
id="minute"
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={minute}
|
||||
onChange={(e) => setMinute(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="task-type">Tipo de Task</Label>
|
||||
<Select value={taskType} onValueChange={(v: any) => setTaskType(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="search_and_summarize">Pesquisar e Resumir</SelectItem>
|
||||
<SelectItem value="just_ping">Apenas Notificação</SelectItem>
|
||||
<SelectItem value="custom_prompt">Prompt Customizado</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{taskType === 'search_and_summarize' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="query">Query de Pesquisa</Label>
|
||||
<Input
|
||||
id="query"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Ex: notícias sobre inteligência artificial hoje"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="model">Modelo</Label>
|
||||
<Input
|
||||
id="model"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="llama3.2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="max-results">Máx. Resultados</Label>
|
||||
<Input
|
||||
id="max-results"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={maxResults}
|
||||
onChange={(e) => setMaxResults(parseInt(e.target.value))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{taskType === 'just_ping' && (
|
||||
<div>
|
||||
<Label htmlFor="message">Mensagem</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Mensagem da notificação"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskType === 'custom_prompt' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="prompt">Prompt</Label>
|
||||
<Textarea
|
||||
id="prompt"
|
||||
value={customPrompt}
|
||||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||
placeholder="Seu prompt customizado aqui"
|
||||
required
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="model-prompt">Modelo</Label>
|
||||
<Input
|
||||
id="model-prompt"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="llama3.2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Criando...' : 'Criar Task'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
-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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle, GraduationCap, Newspaper, Terminal, TrendingUp } from "lucide-react";
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { quickValidate, validateQuery } from "@/lib/query-validation";
|
||||
import { useSettingsStore, type SearchCategory } from "@/store/settings-store";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
onStop: () => void;
|
||||
isLoading: boolean;
|
||||
webSearchEnabled?: boolean;
|
||||
onWebSearchToggle?: (enabled: boolean) => void;
|
||||
categories?: SearchCategory[];
|
||||
onToggleCategory?: (id: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onStop,
|
||||
isLoading,
|
||||
webSearchEnabled = true,
|
||||
onWebSearchToggle,
|
||||
categories = [],
|
||||
onToggleCategory,
|
||||
}: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [webDialogOpen, setWebDialogOpen] = useState(false);
|
||||
const settings = useSettingsStore();
|
||||
|
||||
// Validação em tempo real
|
||||
const validation = useMemo(() => {
|
||||
if (!input.trim()) {
|
||||
return { isValid: true, errors: [], warnings: [] };
|
||||
}
|
||||
const preprocessingConfig = settings.queryPreprocessing || {
|
||||
enabled: true,
|
||||
minLength: 3,
|
||||
maxLength: 2000,
|
||||
autoSplitQuestions: true,
|
||||
irrelevantPatterns: [],
|
||||
};
|
||||
|
||||
if (!preprocessingConfig.enabled) {
|
||||
return { isValid: true, errors: [], warnings: [] };
|
||||
}
|
||||
|
||||
return validateQuery(input, {
|
||||
minLength: preprocessingConfig.minLength,
|
||||
maxLength: preprocessingConfig.maxLength,
|
||||
irrelevantPatterns: preprocessingConfig.irrelevantPatterns.length > 0
|
||||
? preprocessingConfig.irrelevantPatterns
|
||||
: undefined,
|
||||
});
|
||||
}, [input, settings.queryPreprocessing]);
|
||||
|
||||
const isValid = validation.isValid;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (input.trim() && !isLoading && isValid) {
|
||||
onSend(input);
|
||||
setInput("");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
const scrollHeight = textareaRef.current.scrollHeight;
|
||||
// Limitar altura máxima a uns 200px
|
||||
textareaRef.current.style.height = `${Math.min(scrollHeight, 200)}px`;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<div className="w-full bg-transparent">
|
||||
<div className="relative w-full">
|
||||
<div className="flex-1 relative bg-background dark:bg-zinc-900/50 rounded-[2rem] border border-border/50 shadow-lg backdrop-blur-xl transition-all duration-200 focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/40 hover:border-primary/30">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Pergunte qualquer coisa..."
|
||||
className={`min-h-[52px] sm:min-h-[56px] max-h-[200px] w-full resize-none bg-transparent py-3 sm:py-4 pl-4 sm:pl-6 pr-28 sm:pr-32 text-[16px] sm:text-lg rounded-[2rem] border-0 focus-visible:ring-0 shadow-none placeholder:text-muted-foreground/50 ${
|
||||
input.trim() && !isValid ? 'text-destructive' : ''
|
||||
}`}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{/* Right Actions */}
|
||||
<div className="absolute right-2 sm:right-3 bottom-2 sm:bottom-2.5 flex items-center gap-1 sm:gap-1.5">
|
||||
{onWebSearchToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={webSearchEnabled ? "ghost" : "ghost"}
|
||||
onClick={() => {
|
||||
if (!webSearchEnabled) {
|
||||
onWebSearchToggle(true);
|
||||
} else {
|
||||
setWebDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
className={`h-9 w-9 rounded-full transition-all ${
|
||||
webSearchEnabled
|
||||
? "text-primary bg-primary/10 hover:bg-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{webSearchEnabled ? (
|
||||
<Globe className="h-4 w-4" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{webSearchEnabled
|
||||
? "Pesquisa web ativada"
|
||||
: "Pesquisa web desativada"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{onToggleCategory && categories && categories.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="h-9 w-9 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted" title="Gerenciar fontes">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={12} className="w-56 rounded-xl">
|
||||
<DropdownMenuLabel>Fontes de conhecimento</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{categories.map((cat) => {
|
||||
// Mapeamento de ícones por categoria
|
||||
const getCategoryIcon = (categoryId: string) => {
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
'academic': <GraduationCap className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||
'news': <Newspaper className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||
'tech': <Terminal className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||
'finance': <TrendingUp className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||
};
|
||||
return iconMap[categoryId] || null;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={cat.id}
|
||||
checked={cat.enabled}
|
||||
onCheckedChange={(checked) => onToggleCategory(cat.id, !!checked)}
|
||||
className="pl-8"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{getCategoryIcon(cat.id)}
|
||||
<span>{cat.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={onStop}
|
||||
className="h-9 w-9 rounded-full shadow-sm animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !isValid}
|
||||
className={`h-9 w-9 rounded-full transition-all shadow-sm ${
|
||||
input.trim() && isValid
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Send className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Errors */}
|
||||
{input.trim() && !isValid && validation.errors.length > 0 && (
|
||||
<div className="absolute -top-12 left-0 right-0 mx-auto w-max max-w-[90%] animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="bg-destructive/90 text-destructive-foreground text-xs px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 backdrop-blur-sm">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
<span>{validation.errors[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={webDialogOpen} onOpenChange={setWebDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px] rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Desativar pesquisa web?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Desativar a busca pode reduzir a qualidade em perguntas sobre fatos recentes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setWebDialogOpen(false)} className="rounded-full">Cancelar</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => { onWebSearchToggle?.(false); setWebDialogOpen(false); }}
|
||||
className="rounded-full"
|
||||
>
|
||||
Desativar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Message } from "@/hooks/use-chat";
|
||||
import { Bot, User } from "lucide-react";
|
||||
import { markdownRenderers } from "./markdown-renderers";
|
||||
import { MessageActions } from "./message-actions";
|
||||
import { MessageBackground } from "./message-background";
|
||||
import { MessageStepThinking } from "./message-step-thinking";
|
||||
import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) {
|
||||
// Verificar se é mensagem de processo de pensamento
|
||||
const isThinkingMessage = message.metadata &&
|
||||
typeof message.metadata === 'object' &&
|
||||
'type' in message.metadata &&
|
||||
message.metadata.type === 'thinking';
|
||||
|
||||
if (isThinkingMessage) {
|
||||
return <MessageStepThinking metadata={message.metadata as ThinkingMessageMetadata} />;
|
||||
}
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
const [selected, setSelected] = useState(false);
|
||||
const isEmpty = !message.content || message.content.trim() === '';
|
||||
|
||||
// Memoizar processamento de conteúdo
|
||||
const processedContent = useMemo(() => {
|
||||
let content = message.content || '';
|
||||
// Remove metadata tags
|
||||
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||
|
||||
// Also remove common prefixes
|
||||
const prefixes = [
|
||||
'Bloco oculto de metadados:', 'Metadata:', 'Metadados:',
|
||||
'Hidden metadata block:', 'JSON metadata:', '---'
|
||||
];
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
return content || message.content;
|
||||
}, [message.content]);
|
||||
|
||||
return (
|
||||
<MessageBackground
|
||||
role={isUser ? 'user' : 'assistant'}
|
||||
state={selected ? 'selected' : 'normal'}
|
||||
className={cn(
|
||||
"cursor-default transition-colors rounded-xl",
|
||||
selected && "bg-muted/40",
|
||||
isUser ? "mb-2" : "mb-8"
|
||||
)}
|
||||
onClick={() => setSelected((v) => !v)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm ring-1 ring-border/50 backdrop-blur-sm",
|
||||
isUser
|
||||
? "bg-background text-muted-foreground"
|
||||
: "bg-primary/10 text-primary ring-primary/20"
|
||||
)}>
|
||||
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-5 h-5" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden min-w-0 pt-1">
|
||||
{isUser ? (
|
||||
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
|
||||
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap">
|
||||
{processedContent}
|
||||
</div>
|
||||
) : (
|
||||
// Estilo Assistant
|
||||
isEmpty && isStreaming ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium">Gerando resposta...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
|
||||
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
||||
"prose-p:leading-7 prose-li:leading-7",
|
||||
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
|
||||
isStreaming && "relative"
|
||||
)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownRenderers}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && !isEmpty && (
|
||||
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isUser && !isStreaming && (
|
||||
<div className={cn(
|
||||
"absolute -bottom-8 left-12 flex items-center gap-2 opacity-0 transition-opacity duration-200",
|
||||
"group-hover:opacity-100",
|
||||
selected && "opacity-100"
|
||||
)}>
|
||||
<MessageActions content={message.content} role={message.role} />
|
||||
</div>
|
||||
)}
|
||||
</MessageBackground>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface CodeBlockProps {
|
||||
language: string;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function CodeBlock({ language, children }: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(children);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-4 rounded-lg border bg-muted/50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/80 border-b">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{language || "text"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:bg-background/50"
|
||||
onClick={handleCopy}
|
||||
title="Copiar código"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto">
|
||||
<code className="text-sm font-mono whitespace-pre">{children}</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, RefreshCw, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: string | Error;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorDisplay({ error, onRetry, onDismiss, className }: ErrorDisplayProps) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mb-3 px-4 py-3 rounded-lg border border-red-500/50 bg-red-500/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-red-600 dark:text-red-400 mb-1">
|
||||
Erro ao processar
|
||||
</div>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 break-words">
|
||||
{errorMessage}
|
||||
</p>
|
||||
{(onRetry || onDismiss) && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{onRetry && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="h-7 text-xs border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1.5" />
|
||||
Tentar novamente
|
||||
</Button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDismiss}
|
||||
className="h-7 text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<X className="w-3 h-3 mr-1.5" />
|
||||
Fechar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Components } from "react-markdown";
|
||||
import { CodeBlock } from "./code-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const markdownRenderers: Components = {
|
||||
h1: ({ className, ...props }) => (
|
||||
<h1
|
||||
className={cn(
|
||||
"mt-2 scroll-m-20 text-2xl font-bold tracking-tight first:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ className, ...props }) => (
|
||||
<h2
|
||||
className={cn(
|
||||
"mt-6 scroll-m-20 border-b pb-2 text-xl font-semibold tracking-tight first:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h3: ({ className, ...props }) => (
|
||||
<h3
|
||||
className={cn(
|
||||
"mt-4 scroll-m-20 text-lg font-semibold tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h4: ({ className, ...props }) => (
|
||||
<h4
|
||||
className={cn(
|
||||
"mt-4 scroll-m-20 text-base font-semibold tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ className, ...props }) => (
|
||||
<p
|
||||
className={cn("leading-7 [&:not(:first-child)]:mt-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ className, ...props }) => (
|
||||
<ul className={cn("my-4 ml-6 list-disc [&>li]:mt-2", className)} {...props} />
|
||||
),
|
||||
ol: ({ className, ...props }) => (
|
||||
<ol className={cn("my-4 ml-6 list-decimal [&>li]:mt-2", className)} {...props} />
|
||||
),
|
||||
li: ({ className, ...props }) => (
|
||||
<li className={cn("mt-2", className)} {...props} />
|
||||
),
|
||||
blockquote: ({ className, ...props }) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"mt-4 border-l-2 border-primary pl-6 italic text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
img: ({ className, alt, ...props }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className={cn("rounded-md border bg-muted", className)}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: ({ ...props }) => <hr className="my-4 md:my-6" {...props} />,
|
||||
table: ({ className, ...props }) => (
|
||||
<div className="my-4 w-full overflow-y-auto">
|
||||
<table className={cn("w-full", className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
tr: ({ className, ...props }) => (
|
||||
<tr
|
||||
className={cn("m-0 border-t p-0 even:bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
th: ({ className, ...props }) => (
|
||||
<th
|
||||
className={cn(
|
||||
"border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ className, ...props }) => (
|
||||
<td
|
||||
className={cn(
|
||||
"border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: ({ className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const isInline = !match;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlock language={match[1]}>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</CodeBlock>
|
||||
);
|
||||
},
|
||||
a: ({ className, ...props }) => (
|
||||
<a
|
||||
className={cn("font-medium underline underline-offset-4 text-primary hover:text-primary/80", className)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Play, Square, RefreshCw, Save } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { McpJsonEditor } from '@/components/chat/mcp-json-editor';
|
||||
|
||||
interface McpServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface McpConfig {
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
interface McpServerStatus {
|
||||
name: string;
|
||||
status: 'running' | 'stopped' | 'error';
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export function McpConfigPanel() {
|
||||
const [config, setConfig] = useState<McpConfig>({ mcpServers: {} });
|
||||
const [statuses, setStatuses] = useState<McpServerStatus[]>([]);
|
||||
const [configJson, setConfigJson] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [configPath, setConfigPath] = useState('');
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
loadStatus();
|
||||
loadConfigPath();
|
||||
|
||||
// Refresh status every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadStatus();
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const loadedConfig = await invoke<any>('load_mcp_config');
|
||||
// Convert from Rust HashMap to TypeScript Record
|
||||
// Always ensure minimum structure: { mcpServers: {} }
|
||||
const config: McpConfig = {
|
||||
mcpServers: loadedConfig.mcp_servers || loadedConfig.mcpServers || {}
|
||||
};
|
||||
setConfig(config);
|
||||
setConfigJson(JSON.stringify(config, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Failed to load MCP config:', error);
|
||||
// On error, set minimum structure
|
||||
const minConfig: McpConfig = { mcpServers: {} };
|
||||
setConfig(minConfig);
|
||||
setConfigJson(JSON.stringify(minConfig, null, 2));
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: 'Falha ao carregar configuração MCP',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const serverStatuses = await invoke<McpServerStatus[]>('list_mcp_server_status');
|
||||
setStatuses(serverStatuses);
|
||||
} catch (error) {
|
||||
console.error('Failed to load MCP status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfigPath = async () => {
|
||||
try {
|
||||
const path = await invoke<string>('get_mcp_config_path_command');
|
||||
setConfigPath(path);
|
||||
} catch (error) {
|
||||
console.error('Failed to load config path:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Trim whitespace from JSON string
|
||||
const trimmedJson = configJson.trim();
|
||||
|
||||
// Validate JSON structure
|
||||
if (!trimmedJson) {
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: 'JSON vazio. Por favor, insira uma configuração válida.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedConfig: McpConfig;
|
||||
try {
|
||||
parsedConfig = JSON.parse(trimmedJson);
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : 'Erro desconhecido ao fazer parse do JSON';
|
||||
console.error('JSON parse error:', e);
|
||||
toast({
|
||||
title: 'Erro de JSON',
|
||||
description: `JSON inválido: ${errorMessage}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required structure
|
||||
if (!parsedConfig || typeof parsedConfig !== 'object') {
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: 'Configuração inválida: deve ser um objeto JSON.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure mcpServers exists and is an object
|
||||
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
|
||||
parsedConfig.mcpServers = {};
|
||||
}
|
||||
|
||||
// Always ensure minimum structure: { mcpServers: {} }
|
||||
const normalizedConfig: McpConfig = {
|
||||
mcpServers: parsedConfig.mcpServers || {}
|
||||
};
|
||||
|
||||
// The Rust struct uses serde(rename = "mcpServers") so we can pass it directly
|
||||
// No need to convert mcpServers to mcp_servers
|
||||
await invoke('save_mcp_config', { config: normalizedConfig });
|
||||
setConfig(normalizedConfig);
|
||||
// Update JSON to reflect normalized structure
|
||||
setConfigJson(JSON.stringify(normalizedConfig, null, 2));
|
||||
|
||||
toast({
|
||||
title: 'Sucesso',
|
||||
description: 'Configuração MCP salva com sucesso',
|
||||
});
|
||||
|
||||
// Reload status after saving
|
||||
loadStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to save MCP config:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido';
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: `Falha ao salvar configuração MCP: ${errorMessage}`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startServer = async (name: string) => {
|
||||
try {
|
||||
const serverConfig = config.mcpServers[name];
|
||||
if (!serverConfig) {
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: `Servidor '${name}' não encontrado na configuração`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await invoke('start_mcp_server', { name, config: serverConfig });
|
||||
toast({
|
||||
title: 'Sucesso',
|
||||
description: `Servidor '${name}' iniciado`,
|
||||
});
|
||||
loadStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
toast({
|
||||
title: 'Erro ao Iniciar Servidor',
|
||||
description: errorMessage || `Falha ao iniciar servidor '${name}'`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const stopServer = async (name: string) => {
|
||||
try {
|
||||
await invoke('stop_mcp_server', { name });
|
||||
toast({
|
||||
title: 'Sucesso',
|
||||
description: `Servidor '${name}' parado`,
|
||||
});
|
||||
loadStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to stop server:', error);
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: `Falha ao parar servidor '${name}'`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const restartServer = async (name: string) => {
|
||||
try {
|
||||
await invoke('restart_mcp_server', { name });
|
||||
toast({
|
||||
title: 'Sucesso',
|
||||
description: `Servidor '${name}' reiniciado`,
|
||||
});
|
||||
loadStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to restart server:', error);
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: `Falha ao reiniciar servidor '${name}'`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const restartAll = async () => {
|
||||
try {
|
||||
const started = await invoke<string[]>('restart_all_mcp_servers');
|
||||
toast({
|
||||
title: 'Sucesso',
|
||||
description: `${started.length} servidor(es) iniciado(s)`,
|
||||
});
|
||||
loadStatus();
|
||||
} catch (error) {
|
||||
console.error('Failed to restart all servers:', error);
|
||||
toast({
|
||||
title: 'Erro',
|
||||
description: 'Falha ao reiniciar servidores',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'default';
|
||||
case 'stopped':
|
||||
return 'secondary';
|
||||
case 'error':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4 p-4 overflow-hidden">
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">MCP Servers</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gerencie servidores Model Context Protocol
|
||||
</p>
|
||||
{configPath && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Config: <code className="text-xs bg-muted px-1 py-0.5 rounded">{configPath}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={restartAll} size="sm" variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reiniciar Todos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Server Status List */}
|
||||
<div className="space-y-2 flex-shrink-0 overflow-y-auto">
|
||||
{Object.keys(config.mcpServers).length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Nenhum servidor MCP configurado. Adicione servidores no JSON abaixo.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
Object.keys(config.mcpServers).map((name) => {
|
||||
const status = statuses.find(s => s.name === name);
|
||||
const isRunning = status?.status === 'running';
|
||||
|
||||
return (
|
||||
<Card key={name}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{name}</CardTitle>
|
||||
{status && (
|
||||
<Badge variant={getStatusBadgeVariant(status.status)}>
|
||||
{status.status}
|
||||
</Badge>
|
||||
)}
|
||||
{status?.pid && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
PID: {status.pid}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => stopServer(name)}
|
||||
>
|
||||
<Square className="w-3 h-3 mr-1" />
|
||||
Parar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => restartServer(name)}
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Reiniciar
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => startServer(name)}
|
||||
>
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
Iniciar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Comando:</span>{' '}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||
{config.mcpServers[name].command} {config.mcpServers[name].args.join(' ')}
|
||||
</code>
|
||||
</div>
|
||||
{config.mcpServers[name].env && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Env:</span>{' '}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||
{Object.keys(config.mcpServers[name].env || {}).join(', ')}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* JSON Editor */}
|
||||
<Card className="flex flex-col flex-1 min-h-0 max-w-full overflow-hidden">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="text-base">Configuração JSON</CardTitle>
|
||||
<CardDescription className="truncate">
|
||||
Edite a configuração MCP no formato JSON padrão
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button onClick={saveConfig} disabled={isSaving} size="sm">
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Salvar
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 max-w-full overflow-hidden">
|
||||
<McpJsonEditor
|
||||
value={configJson}
|
||||
onChange={setConfigJson}
|
||||
placeholder='{\n "mcpServers": {\n "filesystem": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]\n }\n }\n}'
|
||||
className="flex-1 max-w-full h-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertCircle, CheckCircle2, Wand2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface McpJsonEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function McpJsonEditor({ value, onChange, placeholder, className }: McpJsonEditorProps) {
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isFormatted, setIsFormatted] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const highlightContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Determine theme
|
||||
const isDark = resolvedTheme === 'dark' || theme === 'dark';
|
||||
const codeStyle = isDark ? vscDarkPlus : vs;
|
||||
|
||||
// Validate JSON and update state
|
||||
const validateJson = (jsonString: string) => {
|
||||
if (!jsonString.trim()) {
|
||||
setIsValid(true);
|
||||
setErrorMessage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(jsonString);
|
||||
setIsValid(true);
|
||||
setErrorMessage(null);
|
||||
} catch (e) {
|
||||
setIsValid(false);
|
||||
const error = e instanceof Error ? e.message : 'JSON inválido';
|
||||
setErrorMessage(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Format JSON
|
||||
const formatJson = () => {
|
||||
if (!value.trim()) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
onChange(formatted);
|
||||
setIsFormatted(true);
|
||||
setTimeout(() => setIsFormatted(false), 2000);
|
||||
} catch (e) {
|
||||
// If invalid, show error
|
||||
validateJson(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input changes
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
validateJson(newValue);
|
||||
setIsFormatted(false);
|
||||
};
|
||||
|
||||
// Sync scroll between textarea and highlight
|
||||
const handleScroll = () => {
|
||||
if (textareaRef.current && highlightContainerRef.current) {
|
||||
const scrollTop = textareaRef.current.scrollTop;
|
||||
const scrollLeft = textareaRef.current.scrollLeft;
|
||||
|
||||
const pre = highlightContainerRef.current.querySelector('pre');
|
||||
if (pre) {
|
||||
pre.scrollTop = scrollTop;
|
||||
pre.scrollLeft = scrollLeft;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Validate on mount and when value changes externally
|
||||
useEffect(() => {
|
||||
validateJson(value);
|
||||
}, [value]);
|
||||
|
||||
// Count lines for display
|
||||
const lineCount = value.split('\n').length;
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex flex-col h-full min-h-0', className)}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={formatJson}
|
||||
disabled={!value.trim()}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Wand2 className="w-3 h-3 mr-1" />
|
||||
Formatar
|
||||
</Button>
|
||||
{isFormatted && (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Formatado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{lineCount > 0 && <span>{lineCount} linha{lineCount !== 1 ? 's' : ''}</span>}
|
||||
{!isValid && errorMessage && (
|
||||
<div className="flex items-center gap-1 text-destructive">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span className="max-w-[200px] truncate">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{isValid && value.trim() && (
|
||||
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Válido
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Container */}
|
||||
<div className="relative flex-1 min-h-[300px] border rounded-md overflow-hidden bg-background">
|
||||
{/* Syntax Highlighted Background */}
|
||||
<div ref={highlightContainerRef} className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<SyntaxHighlighter
|
||||
language="json"
|
||||
style={codeStyle}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '0.75rem',
|
||||
background: 'transparent',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.5rem',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
}}
|
||||
PreTag="pre"
|
||||
>
|
||||
{value || placeholder || '{}'}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
|
||||
{/* Editable Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onScroll={handleScroll}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'relative w-full h-full min-h-[300px] p-3 font-mono text-sm leading-6',
|
||||
'bg-transparent text-transparent caret-foreground',
|
||||
'resize-none border-none outline-none',
|
||||
'selection:bg-primary/20',
|
||||
'[&::placeholder]:text-muted-foreground/50',
|
||||
!isValid && 'ring-1 ring-destructive'
|
||||
)}
|
||||
spellCheck={false}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
overflow: 'auto',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Server, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { McpServerInfo } from '@/hooks/use-mcp-servers';
|
||||
|
||||
interface McpServerSelectorProps {
|
||||
servers: McpServerInfo[];
|
||||
selectedServers: string[];
|
||||
onSelectionChange: (servers: string[]) => void;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function McpServerSelector({
|
||||
servers,
|
||||
selectedServers,
|
||||
onSelectionChange,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: McpServerSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Filter only running servers
|
||||
const runningServers = servers.filter(s => s.status === 'running');
|
||||
|
||||
const handleToggle = (serverName: string) => {
|
||||
if (selectedServers.includes(serverName)) {
|
||||
onSelectionChange(selectedServers.filter(s => s !== serverName));
|
||||
} else {
|
||||
onSelectionChange([...selectedServers, serverName]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedServers.length === runningServers.length) {
|
||||
onSelectionChange([]);
|
||||
} else {
|
||||
onSelectionChange(runningServers.map(s => s.name));
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (selectedServers.length === 0) {
|
||||
return 'Servidores MCP';
|
||||
}
|
||||
if (selectedServers.length === 1) {
|
||||
return selectedServers[0];
|
||||
}
|
||||
return `${selectedServers.length} servidores`;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('h-8 gap-2', className)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{getButtonText()}</span>
|
||||
{selectedServers.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-xs">
|
||||
{selectedServers.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
<span>Servidores MCP</span>
|
||||
{selectedServers.length > 0 && (
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{selectedServers.length} selecionado{selectedServers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={selectedServers.length === runningServers.length && runningServers.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
className="font-medium"
|
||||
>
|
||||
Selecionar Todos
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
{runningServers.map((server) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={server.name}
|
||||
checked={selectedServers.includes(server.name)}
|
||||
onCheckedChange={() => handleToggle(server.name)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
|
||||
<span>{server.name}</span>
|
||||
</div>
|
||||
{server.toolCount !== undefined && server.toolCount > 0 && (
|
||||
<Badge variant="outline" className="ml-2 h-5 px-1.5 text-xs">
|
||||
{server.toolCount}
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
{runningServers.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
Nenhum servidor rodando
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Copy, FileJson, FileText, MoreHorizontal, Contrast } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function MessageActions({ content, role }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [highContrast, setHighContrast] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = typeof window !== 'undefined' ? localStorage.getItem('highContrast') === 'true' : false;
|
||||
setHighContrast(saved);
|
||||
if (typeof document !== 'undefined') {
|
||||
if (saved) document.documentElement.setAttribute('data-high-contrast', 'true');
|
||||
else document.documentElement.removeAttribute('data-high-contrast');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const copyMarkdown = () => handleCopy(content);
|
||||
|
||||
const copyText = () => {
|
||||
// Simple strip markdown (can be improved with a library if needed)
|
||||
const text = content
|
||||
.replace(/#{1,6}\s/g, '') // Headers
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2') // Bold
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2') // Italic
|
||||
.replace(/`{3}[\s\S]*?`{3}/g, '$&') // Keep code blocks but maybe strip fences? For now keep raw
|
||||
.replace(/`(.+?)`/g, '$1') // Inline code
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)') // Links
|
||||
.replace(/>\s/g, ''); // Blockquotes
|
||||
handleCopy(text);
|
||||
};
|
||||
|
||||
const copyJson = () => {
|
||||
const json = JSON.stringify({ role, content }, null, 2);
|
||||
handleCopy(json);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={copyMarkdown}
|
||||
title="Copiar Markdown"
|
||||
>
|
||||
{copied ? (
|
||||
<span className="text-green-500 text-xs font-bold">✓</span>
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={copyText}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
<span>Copiar Texto Puro</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyJson}>
|
||||
<FileJson className="mr-2 h-4 w-4" />
|
||||
<span>Copiar JSON</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const next = !highContrast;
|
||||
setHighContrast(next);
|
||||
if (typeof document !== 'undefined') {
|
||||
if (next) document.documentElement.setAttribute('data-high-contrast', 'true');
|
||||
else document.documentElement.removeAttribute('data-high-contrast');
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('highContrast', String(next));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Contrast className="mr-2 h-4 w-4" />
|
||||
<span>{highContrast ? 'Desativar Alto Contraste' : 'Ativar Alto Contraste'}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MessageRole = "user" | "assistant";
|
||||
type MessageVisualState = "normal" | "highlighted" | "selected";
|
||||
|
||||
interface MessageBackgroundProps extends React.ComponentProps<"div"> {
|
||||
role: MessageRole;
|
||||
state?: MessageVisualState;
|
||||
highContrast?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBackground({
|
||||
role,
|
||||
state = "normal",
|
||||
highContrast = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageBackgroundProps) {
|
||||
const base = "group flex w-full gap-4 md:gap-6 relative transition-all duration-200";
|
||||
|
||||
// User: Fundo sutil, alinhado à direita (se quiséssemos, mas o layout atual é lista).
|
||||
// Vamos manter layout lista mas com estilo clean.
|
||||
// Perplexity usa: User message apenas texto bold/destacado, Assistant texto normal.
|
||||
|
||||
const roleCl = role === "user"
|
||||
? "bg-transparent text-foreground px-0 py-2" // Minimalista
|
||||
: "bg-transparent text-foreground/90 px-0 py-2";
|
||||
|
||||
// Estado de seleção sutil
|
||||
const stateCl = state === "selected" ? "bg-muted/30 -mx-4 px-4 rounded-lg" : "";
|
||||
|
||||
const hc = highContrast ? "hc:bg-message-high-contrast hc:text-foreground" : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-selected={state === "selected"}
|
||||
className={cn(base, roleCl, stateCl, hc, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Search,
|
||||
Brain,
|
||||
Globe,
|
||||
Package,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||
|
||||
interface MessageStepThinkingProps {
|
||||
metadata: ThinkingMessageMetadata;
|
||||
}
|
||||
|
||||
export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
const getStepIcon = () => {
|
||||
const isSpinning = metadata.stepType === 'response-generation' && metadata.status === 'running';
|
||||
|
||||
switch (metadata.stepType) {
|
||||
case 'preprocessing':
|
||||
return <Brain className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||
case 'web-research':
|
||||
return <Globe className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||
case 'sources-found':
|
||||
return <Package className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||
case 'processing':
|
||||
return <Brain className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||
case 'response-generation':
|
||||
return <Loader2 className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||
case 'fallback':
|
||||
return <AlertCircle className="w-4 h-4 text-muted-foreground/80" />;
|
||||
case 'error':
|
||||
return <XCircle className="w-4 h-4 text-destructive/80" />;
|
||||
default:
|
||||
return <Search className="w-4 h-4 text-muted-foreground/80" />;
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (url: string, index: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 ml-10 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="flex items-center gap-3 p-2 transition-all w-fit max-w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStepIcon()}
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
metadata.status === 'error'
|
||||
? "text-destructive/80"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{metadata.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{metadata.status === 'running' && (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground/50" />
|
||||
)}
|
||||
|
||||
{(metadata.details || metadata.sources || metadata.error) && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-3.5 h-3.5 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar Compacta */}
|
||||
{metadata.progress !== undefined && metadata.progress < 100 && (
|
||||
<div className="ml-1 mt-1 w-32 h-1 bg-muted/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary/50 transition-all duration-300"
|
||||
style={{ width: `${metadata.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conteúdo Expandido */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-2 text-sm text-muted-foreground animate-in fade-in">
|
||||
{metadata.details && (
|
||||
<div className="whitespace-pre-wrap">{metadata.details}</div>
|
||||
)}
|
||||
|
||||
{metadata.sources && metadata.sources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{metadata.sources.map((source, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
|
||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||
<a href={source.url} target="_blank" className="truncate hover:underline flex-1">{source.title || source.url}</a>
|
||||
<button onClick={() => copyToClipboard(source.url, idx)} className="ml-1 opacity-50 hover:opacity-100">
|
||||
{copiedIndex === idx ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadata.error && (
|
||||
<div className="text-red-500 font-medium flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4" />
|
||||
{metadata.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Download, Loader2, X, Upload, Globe, AlertCircle, File } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
// Usar file dialog do Tauri v2
|
||||
|
||||
interface ModelDownloadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: (modelName: string) => void;
|
||||
}
|
||||
|
||||
interface DownloadInfo {
|
||||
status: string;
|
||||
percent?: number;
|
||||
downloaded?: string;
|
||||
total?: string;
|
||||
speed?: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) {
|
||||
const [activeTab, setActiveTab] = useState<"download" | "local">("download");
|
||||
const [modelName, setModelName] = useState("");
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [downloadStatus, setDownloadStatus] = useState("");
|
||||
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
const isDownloadingRef = useRef(false);
|
||||
const modelNameRef = useRef("");
|
||||
|
||||
// Estados para instalação local
|
||||
const [isInstallingLocal, setIsInstallingLocal] = useState(false);
|
||||
const [localInstallProgress, setLocalInstallProgress] = useState(0);
|
||||
const [localInstallError, setLocalInstallError] = useState<string | null>(null);
|
||||
const [localModelName, setLocalModelName] = useState("");
|
||||
|
||||
// Atualizar refs quando estado muda
|
||||
useEffect(() => {
|
||||
isDownloadingRef.current = isDownloading;
|
||||
modelNameRef.current = modelName;
|
||||
}, [isDownloading, modelName]);
|
||||
|
||||
// Resetar estado quando dialog abre/fecha
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab("download");
|
||||
setModelName("");
|
||||
setIsDownloading(false);
|
||||
setDownloadProgress(0);
|
||||
setDownloadStatus("");
|
||||
setDownloadInfo(null);
|
||||
setError(null);
|
||||
setIsInstalled(false);
|
||||
isDownloadingRef.current = false;
|
||||
modelNameRef.current = "";
|
||||
// Reset estados locais
|
||||
setIsInstallingLocal(false);
|
||||
setLocalInstallProgress(0);
|
||||
setLocalInstallError(null);
|
||||
setLocalModelName("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Verificar se modelo já está instalado quando nome muda
|
||||
useEffect(() => {
|
||||
if (modelName.trim() && !isDownloading) {
|
||||
checkInstallation(modelName.trim());
|
||||
}
|
||||
}, [modelName, isDownloading]);
|
||||
|
||||
// Listener para progresso do download - sempre ativo quando dialog está aberto
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
let unlistenFn: (() => void) | null = null;
|
||||
|
||||
const setupListener = async () => {
|
||||
const unlisten = await listen<string>('download-progress', (event) => {
|
||||
// Só processar se estiver fazendo download (usar ref para evitar stale closure)
|
||||
if (!isDownloadingRef.current) return;
|
||||
|
||||
const payload = event.payload;
|
||||
|
||||
// Tentar parsear como JSON estruturado
|
||||
let info: DownloadInfo | null = null;
|
||||
try {
|
||||
info = JSON.parse(payload) as DownloadInfo;
|
||||
} catch {
|
||||
// Fallback: tratar como string raw
|
||||
info = {
|
||||
status: "downloading",
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (info) {
|
||||
setDownloadInfo(info);
|
||||
|
||||
// Atualizar status text
|
||||
const statusText = info.status === "pulling" ? "Baixando modelo..." :
|
||||
info.status === "verifying" ? "Verificando..." :
|
||||
info.status === "writing" ? "Salvando..." :
|
||||
info.status === "success" ? "Concluído!" :
|
||||
"Baixando...";
|
||||
setDownloadStatus(statusText);
|
||||
|
||||
// Atualizar progresso
|
||||
if (info.percent !== undefined) {
|
||||
setDownloadProgress(info.percent);
|
||||
} else {
|
||||
// Fallback: estimar progresso baseado no status
|
||||
if (info.status === "pulling") {
|
||||
setDownloadProgress(prev => prev < 10 ? 10 : prev);
|
||||
} else if (info.status === "verifying") {
|
||||
setDownloadProgress(90);
|
||||
} else if (info.status === "writing") {
|
||||
setDownloadProgress(95);
|
||||
}
|
||||
}
|
||||
|
||||
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay
|
||||
if (info.percent === 100 || info.status === "success") {
|
||||
setTimeout(async () => {
|
||||
setIsInstalled(true);
|
||||
setIsDownloading(false);
|
||||
isDownloadingRef.current = false;
|
||||
const currentModelName = modelNameRef.current.trim();
|
||||
await checkInstallation(currentModelName);
|
||||
// Após verificar instalação, chamar onSuccess e fechar
|
||||
if (onSuccess) {
|
||||
onSuccess(currentModelName);
|
||||
}
|
||||
onOpenChange(false);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
unlistenFn = unlisten;
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlistenFn) {
|
||||
unlistenFn();
|
||||
}
|
||||
};
|
||||
}, [open, onSuccess, onOpenChange]);
|
||||
|
||||
const checkInstallation = async (name: string) => {
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_if_model_installed', { name });
|
||||
setIsInstalled(installed);
|
||||
if (installed) {
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Check failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const validateModelName = (name: string): string | null => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return "Nome do modelo não pode estar vazio";
|
||||
}
|
||||
// Validar formato: aceita letras, números, pontos, underscores, hífens, dois pontos e barras
|
||||
// Exemplos válidos: "llama3.2:1b", "huihui_ai/qwen3-abliterated:1.7b", "mistral:latest"
|
||||
if (!/^[a-zA-Z0-9._:\/-]+$/.test(trimmed)) {
|
||||
return "Nome do modelo contém caracteres inválidos";
|
||||
}
|
||||
// Não pode começar ou terminar com barra, dois pontos ou hífen
|
||||
if (/^[\/:\-]|[\/:\-]$/.test(trimmed)) {
|
||||
return "Nome do modelo não pode começar ou terminar com /, : ou -";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const trimmedName = modelName.trim();
|
||||
const validationError = validateModelName(trimmedName);
|
||||
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
// Se já está instalado, apenas fechar e notificar sucesso
|
||||
handleSuccess(trimmedName);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloading(true);
|
||||
isDownloadingRef.current = true;
|
||||
setDownloadProgress(0);
|
||||
setDownloadStatus("Iniciando download...");
|
||||
setError(null);
|
||||
modelNameRef.current = trimmedName;
|
||||
|
||||
try {
|
||||
await invoke('pull_model', { name: trimmedName });
|
||||
// O status será atualizado pelo listener quando o download completar
|
||||
} catch (error: any) {
|
||||
console.error("Download failed", error);
|
||||
setError(error?.message || "Falha no download. Tente novamente.");
|
||||
setDownloadStatus("");
|
||||
setDownloadProgress(0);
|
||||
setIsDownloading(false);
|
||||
isDownloadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = (name: string) => {
|
||||
// Só fechar e chamar onSuccess se realmente completou o download
|
||||
// Se já estava instalado, também chamar onSuccess
|
||||
if (onSuccess) {
|
||||
onSuccess(name);
|
||||
}
|
||||
// Não fechar automaticamente se foi apenas verificação de instalação
|
||||
// Deixar o usuário decidir quando fechar
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Permitir fechar sempre, mas avisar se estiver baixando
|
||||
if (isDownloading) {
|
||||
const confirmed = window.confirm("O download está em andamento. Deseja realmente fechar? O download continuará em segundo plano.");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Cancelar download (por enquanto apenas fecha o dialog e para o estado)
|
||||
// TODO: Implementar cancelamento real do processo ollama pull
|
||||
if (isDownloading) {
|
||||
const confirmed = window.confirm("Deseja cancelar o download? O processo continuará em segundo plano.");
|
||||
if (confirmed) {
|
||||
setIsDownloading(false);
|
||||
isDownloadingRef.current = false;
|
||||
setDownloadProgress(0);
|
||||
setDownloadStatus("");
|
||||
onOpenChange(false);
|
||||
}
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFileButton = async () => {
|
||||
try {
|
||||
// Usar comando Rust para abrir dialog nativo do sistema
|
||||
// Isso evita limitações do input file HTML5 com arquivos grandes
|
||||
const selectedPath = await invoke<string | null>('open_gguf_file_dialog');
|
||||
|
||||
if (!selectedPath) {
|
||||
// Usuário cancelou
|
||||
return;
|
||||
}
|
||||
|
||||
setIsInstallingLocal(true);
|
||||
setLocalInstallError(null);
|
||||
setLocalInstallProgress(0);
|
||||
|
||||
try {
|
||||
setLocalInstallProgress(10);
|
||||
|
||||
// Extrair nome do arquivo do caminho
|
||||
const pathParts = selectedPath.split(/[/\\]/);
|
||||
const fileName = pathParts[pathParts.length - 1] || 'model';
|
||||
const fileNameLower = fileName.toLowerCase();
|
||||
const hasGgufExtension = fileNameLower.endsWith('.gguf');
|
||||
|
||||
// Extrair nome do modelo (remover extensão se for .gguf, senão manter)
|
||||
const fileNameWithoutExt = hasGgufExtension
|
||||
? fileName.replace(/\.gguf$/i, '')
|
||||
: fileName;
|
||||
const modelNameToUse = localModelName.trim() || fileNameWithoutExt;
|
||||
|
||||
setLocalInstallProgress(30);
|
||||
|
||||
// Instalar diretamente do caminho (sem precisar copiar para temp primeiro)
|
||||
const installedModelName = await invoke<string>('install_gguf_model', {
|
||||
filePath: selectedPath,
|
||||
modelName: modelNameToUse || undefined,
|
||||
});
|
||||
|
||||
setLocalInstallProgress(100);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(installedModelName);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
}, 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
const errorMsg = err?.message || 'Erro ao instalar modelo GGUF';
|
||||
setLocalInstallError(errorMsg);
|
||||
console.error('Erro ao instalar modelo GGUF:', err);
|
||||
} finally {
|
||||
setIsInstallingLocal(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Erro ao abrir dialog de arquivo:', err);
|
||||
setLocalInstallError('Erro ao abrir seletor de arquivo');
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = modelName.trim().length > 0 && !validateModelName(modelName.trim());
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adicionar Modelo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Baixe modelos do Ollama ou instale arquivos GGUF locais
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "download" | "local")} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="download" className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Baixar do Ollama
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="local" className="flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Instalar Local
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="download" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-name">Nome do Modelo</Label>
|
||||
<Input
|
||||
id="model-name"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
placeholder="ex: llama3.2:1b"
|
||||
disabled={isDownloading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && isValid && !isDownloading) {
|
||||
handleDownload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
{isInstalled && !isDownloading && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
✓ Este modelo já está instalado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDownloading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{downloadStatus || "Iniciando..."}</span>
|
||||
<span>{downloadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={downloadProgress} className="h-2" />
|
||||
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{downloadInfo.downloaded && downloadInfo.total
|
||||
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
|
||||
: downloadInfo.downloaded || ""}
|
||||
</span>
|
||||
{downloadInfo.speed && (
|
||||
<span>{downloadInfo.speed}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInstalled && !isDownloading && (
|
||||
<div className="bg-muted/50 p-3 rounded-md text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
O modelo está instalado e pronto para uso.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="local" className="space-y-4 mt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="local-model-name">Nome do Modelo (opcional)</Label>
|
||||
<Input
|
||||
id="local-model-name"
|
||||
value={localModelName}
|
||||
onChange={(e) => setLocalModelName(e.target.value)}
|
||||
placeholder="Deixe vazio para usar o nome do arquivo"
|
||||
disabled={isInstallingLocal}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Se não especificar, o nome será extraído do arquivo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isInstallingLocal ? (
|
||||
<div className="border rounded-xl p-6 bg-muted/30 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Instalando modelo...</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Processando arquivo...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localInstallProgress > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={localInstallProgress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{localInstallProgress}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="border-2 border-dashed rounded-xl p-8 text-center bg-muted/30">
|
||||
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Selecione um arquivo GGUF para instalar localmente
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSelectFileButton}
|
||||
disabled={isInstallingLocal}
|
||||
className="w-full"
|
||||
>
|
||||
<File className="w-4 h-4 mr-2" />
|
||||
Selecionar arquivo GGUF
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{localInstallError && (
|
||||
<div className="border border-destructive/50 rounded-xl p-4 bg-destructive/5 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm text-destructive">Erro ao instalar modelo</p>
|
||||
<p className="text-xs text-destructive/80 mt-1">{localInstallError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 border border-border/50 rounded-lg p-3 text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-1">ℹ️ Modelos instalados localmente</p>
|
||||
<p>Funcionam sem conexão à internet. O arquivo será copiado para o diretório do Ollama.</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isInstallingLocal}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
{activeTab === "download" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isInstalled && !isDownloading) {
|
||||
handleSuccess(modelName.trim());
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
handleDownload();
|
||||
}
|
||||
}}
|
||||
disabled={!isValid || isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Baixando...
|
||||
</>
|
||||
) : isInstalled ? (
|
||||
<>
|
||||
Usar Modelo
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Baixar
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown, ChevronUp, Clock, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ProcessStep {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
timestamp?: number;
|
||||
duration?: number;
|
||||
details?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PostProcessingPanelProps {
|
||||
steps: ProcessStep[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PostProcessingPanel({ steps, className }: PostProcessingPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (!steps || steps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedSteps = steps.filter(s => s.status === 'completed').length;
|
||||
const hasErrors = steps.some(s => s.status === 'error');
|
||||
const isRunning = steps.some(s => s.status === 'running');
|
||||
|
||||
const getStepIcon = (step: ProcessStep) => {
|
||||
switch (step.status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />;
|
||||
case 'error':
|
||||
return <XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />;
|
||||
case 'running':
|
||||
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms?: number) => {
|
||||
if (!ms) return '';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("border-t border-muted bg-muted/20", className)}>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-6 py-3 flex items-center justify-between hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasErrors ? (
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
) : isRunning ? (
|
||||
<Loader2 className="w-4 h-4 text-primary animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
Processo de Pensamento
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({completedSteps}/{steps.length} etapas)
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-6 pb-4 space-y-3">
|
||||
{steps.map((step, idx) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border transition-all",
|
||||
step.status === 'completed' && "bg-green-500/5 border-green-500/20",
|
||||
step.status === 'error' && "bg-red-500/5 border-red-500/20",
|
||||
step.status === 'running' && "bg-primary/5 border-primary/20",
|
||||
step.status === 'pending' && "bg-muted/50 border-muted"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
{getStepIcon(step)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{step.label}</span>
|
||||
{step.duration && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatDuration(step.duration)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{step.details && (
|
||||
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap">
|
||||
{step.details}
|
||||
</p>
|
||||
)}
|
||||
{step.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{step.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { Search, Brain, Package, Loader2, CheckCircle2, Globe } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export type ProcessingStep =
|
||||
| 'collecting'
|
||||
| 'processing'
|
||||
| 'searching'
|
||||
| 'preparing'
|
||||
| 'streaming';
|
||||
|
||||
interface ProcessingIndicatorProps {
|
||||
currentStep: ProcessingStep;
|
||||
progress?: number; // 0-100
|
||||
sourcesCount?: number;
|
||||
}
|
||||
|
||||
export function ProcessingIndicator({
|
||||
currentStep,
|
||||
progress = 0,
|
||||
sourcesCount = 0,
|
||||
}: ProcessingIndicatorProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fade-in animation
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
|
||||
const steps: Array<{
|
||||
key: ProcessingStep;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
emoji: string;
|
||||
}> = [
|
||||
{
|
||||
key: 'collecting',
|
||||
label: 'Coletando Dados',
|
||||
icon: <Search className="w-4 h-4" />,
|
||||
emoji: '🔍',
|
||||
},
|
||||
{
|
||||
key: 'processing',
|
||||
label: 'Processando...',
|
||||
icon: <Brain className="w-4 h-4" />,
|
||||
emoji: '🤖',
|
||||
},
|
||||
{
|
||||
key: 'searching',
|
||||
label: sourcesCount > 0 ? `Buscando na Web (${sourcesCount} fontes)...` : 'Buscando na Web...',
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
emoji: '🌐',
|
||||
},
|
||||
{
|
||||
key: 'preparing',
|
||||
label: 'Preparando Contexto',
|
||||
icon: <Package className="w-4 h-4" />,
|
||||
emoji: '📦',
|
||||
},
|
||||
{
|
||||
key: 'streaming',
|
||||
label: 'Gerando Resposta...',
|
||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||
emoji: '💬',
|
||||
},
|
||||
];
|
||||
|
||||
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
|
||||
const currentStepData = steps[currentStepIndex] || steps[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mb-3 px-4 py-3 rounded-lg border border-muted/50 bg-muted/20 transition-all duration-300",
|
||||
isVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
|
||||
)}
|
||||
>
|
||||
{/* Card de Status Atual */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{currentStep === 'collecting' && (
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-ping" />
|
||||
)}
|
||||
{currentStep === 'processing' && (
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-pulse" />
|
||||
)}
|
||||
{currentStep === 'searching' && (
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-spin" style={{ animationDuration: '2s' }} />
|
||||
)}
|
||||
<span className="text-xl relative z-10">{currentStepData.emoji}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentStepData.label}
|
||||
</div>
|
||||
{progress > 0 && progress < 100 && (
|
||||
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Visual */}
|
||||
<div className="hidden md:flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
|
||||
const isActive = idx === currentStepIndex;
|
||||
const isPast = idx < currentStepIndex;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-300",
|
||||
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
|
||||
isActive && "bg-primary/20 text-primary animate-pulse",
|
||||
!isActive && !isPast && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPast ? (
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
) : isActive ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
step.icon
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-colors duration-300",
|
||||
isActive && "text-foreground font-medium",
|
||||
isPast && "text-muted-foreground opacity-60"
|
||||
)}
|
||||
>
|
||||
{step.label.split(' ')[0]} {/* Mostrar apenas primeira palavra na timeline */}
|
||||
</span>
|
||||
{idx < currentStepIndex && (
|
||||
<div className="w-6 h-px bg-muted mx-1" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Timeline Vertical para Mobile */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
|
||||
const isActive = idx === currentStepIndex;
|
||||
const isPast = idx < currentStepIndex;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-5 h-5 rounded-full transition-all",
|
||||
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
|
||||
isActive && "bg-primary/20 text-primary animate-pulse",
|
||||
!isActive && !isPast && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPast ? (
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
) : isActive ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
step.icon
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs transition-colors",
|
||||
isActive && "text-foreground font-medium",
|
||||
isPast && "text-muted-foreground opacity-60"
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Sparkles, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePromptGenerator } from "@/hooks/use-prompt-generator";
|
||||
import { useLocalModels } from "@/hooks/use-local-models";
|
||||
|
||||
interface PromptGeneratorDialogProps {
|
||||
defaultModel: string;
|
||||
onPromptGenerated: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: PromptGeneratorDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [goal, setGoal] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState(defaultModel || "");
|
||||
const { generatePrompt, isGenerating } = usePromptGenerator();
|
||||
const { models, loading } = useLocalModels();
|
||||
|
||||
// Set default model when dialog opens or defaultModel changes
|
||||
useEffect(() => {
|
||||
if (defaultModel && defaultModel.trim()) {
|
||||
setSelectedModel(defaultModel);
|
||||
}
|
||||
}, [defaultModel]);
|
||||
|
||||
// Try to set specific default if available and no model is selected
|
||||
useEffect(() => {
|
||||
if (loading || models.length === 0) return;
|
||||
|
||||
// Se já tem um modelo selecionado e válido, não alterar
|
||||
if (selectedModel && models.some(m => m.name === selectedModel)) return;
|
||||
|
||||
// Primeiro, tenta usar o defaultModel se for válido
|
||||
if (defaultModel && defaultModel.trim() && models.some(m => m.name === defaultModel)) {
|
||||
setSelectedModel(defaultModel);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback para modelo preferido
|
||||
const preferredModel = "llama3.2-abliterate:3b";
|
||||
const hasPreferred = models.some(m => m.name === preferredModel);
|
||||
if (hasPreferred && !selectedModel.trim()) {
|
||||
setSelectedModel(preferredModel);
|
||||
return;
|
||||
}
|
||||
|
||||
// Se nenhum modelo está selecionado, seleciona o primeiro disponível
|
||||
if (!selectedModel.trim() && models.length > 0) {
|
||||
setSelectedModel(models[0].name);
|
||||
}
|
||||
}, [models, loading, defaultModel, selectedModel]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!goal.trim() || !selectedModel.trim()) return;
|
||||
|
||||
try {
|
||||
const prompt = await generatePrompt(goal, selectedModel);
|
||||
onPromptGenerated(prompt);
|
||||
setOpen(false);
|
||||
setGoal("");
|
||||
} catch (error) {
|
||||
console.error("Erro ao gerar prompt:", error);
|
||||
// TODO: Implementar toast de erro quando disponível
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
setGoal("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-yellow-500 hover:text-yellow-600 hover:bg-yellow-100 dark:hover:bg-yellow-900/20" title="Gerar com IA">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gerador de Prompts</DialogTitle>
|
||||
<DialogDescription>
|
||||
Descreva o que você quer que a IA faça, e nós criaremos um System Prompt otimizado para você.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="model-select" className="text-sm font-medium">
|
||||
Modelo para Geração
|
||||
</label>
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
value={selectedModel || undefined}
|
||||
onValueChange={setSelectedModel}
|
||||
disabled={loading || models.length === 0}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
|
||||
<SelectValue placeholder={
|
||||
loading
|
||||
? "Carregando modelos..."
|
||||
: models.length === 0
|
||||
? "Nenhum modelo disponível"
|
||||
: "Selecione um modelo..."
|
||||
} className="truncate" />
|
||||
</SelectTrigger>
|
||||
</TooltipTrigger>
|
||||
{selectedModel && (
|
||||
<TooltipContent side="top" className="max-w-[70vw] break-words">
|
||||
<p className="text-sm">{selectedModel}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<SelectContent className="max-w-[90vw]">
|
||||
{models.length === 0 ? (
|
||||
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
|
||||
) : (
|
||||
models.map(m => (
|
||||
<Tooltip key={m.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem value={m.name} className="min-w-0">
|
||||
<span className="truncate block" style={{ maxWidth: '70vw' }}>
|
||||
{m.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
{m.name.length > 40 && (
|
||||
<TooltipContent side="right" className="max-w-[70vw] break-words">
|
||||
<p className="text-sm">{m.name}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TooltipProvider>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="goal-textarea" className="text-sm font-medium">
|
||||
Seu Objetivo
|
||||
</label>
|
||||
<Textarea
|
||||
id="goal-textarea"
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
placeholder="Ex: Quero um assistente especialista em Python que explique conceitos complexos de forma simples..."
|
||||
className="min-h-[160px]"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleCancel} disabled={isGenerating}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !goal.trim() || !selectedModel.trim() || models.length === 0}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Gerando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Gerar Prompt
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Search,
|
||||
Globe,
|
||||
Brain,
|
||||
Package,
|
||||
BookOpen
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||
import { SourcesCompact } from './sources-compact';
|
||||
|
||||
interface ReasoningChainProps {
|
||||
steps: ThinkingMessageMetadata[];
|
||||
searchQueries?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ReasoningChain({ steps, searchQueries = [], className }: ReasoningChainProps) {
|
||||
const hasActiveStep = steps.some(step => step.status === 'running');
|
||||
const allSources = steps
|
||||
.flatMap(step => step.sources || [])
|
||||
.filter((source, index, self) =>
|
||||
index === self.findIndex(s => s.url === source.url)
|
||||
);
|
||||
|
||||
const getStepIcon = (step: ThinkingMessageMetadata) => {
|
||||
const isRunning = step.status === 'running';
|
||||
const isCompleted = step.status === 'completed';
|
||||
const isError = step.status === 'error';
|
||||
|
||||
if (isError) {
|
||||
return null; // Não mostrar ícone para erros, apenas texto
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
return <CheckCircle2 className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
return <Loader2 className="w-3.5 h-3.5 text-muted-foreground/60 animate-spin" />;
|
||||
}
|
||||
|
||||
// Ícone baseado no tipo de step
|
||||
switch (step.stepType) {
|
||||
case 'preprocessing':
|
||||
return <Brain className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||
case 'web-research':
|
||||
return <Search className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||
case 'sources-found':
|
||||
return <Globe className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||
case 'processing':
|
||||
return <Brain className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||
case 'response-generation':
|
||||
return <Loader2 className="w-3.5 h-3.5 text-muted-foreground/60 animate-spin" />;
|
||||
default:
|
||||
return <Search className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("mb-6 space-y-3", className)}>
|
||||
{/* Header: "Working..." com spinner sutil */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Circle className="w-2 h-2 fill-current animate-pulse" />
|
||||
<span>Working...</span>
|
||||
</div>
|
||||
|
||||
{/* Lista vertical de steps - texto simples */}
|
||||
<div className="space-y-1.5 pl-6">
|
||||
{steps.map((step, index) => {
|
||||
const isRunning = step.status === 'running';
|
||||
const isCompleted = step.status === 'completed';
|
||||
const isError = step.status === 'error';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${step.stepType}-${step.timestamp}`}
|
||||
className={cn(
|
||||
"reasoning-step flex items-center gap-2 text-sm",
|
||||
isError
|
||||
? "text-destructive/80"
|
||||
: isCompleted
|
||||
? "text-muted-foreground/60"
|
||||
: "text-muted-foreground/80"
|
||||
)}
|
||||
style={{ animationDelay: `${index * 100}ms` }}
|
||||
>
|
||||
{getStepIcon(step)}
|
||||
<span>{step.label}</span>
|
||||
{step.details && (
|
||||
<span className="text-xs text-muted-foreground/50 ml-1">
|
||||
{step.details}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Pills de busca - cápsulas sutis */}
|
||||
{searchQueries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pl-6">
|
||||
<span className="text-xs text-muted-foreground/60">Searching:</span>
|
||||
{searchQueries.map((query, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="text-xs font-mono px-2 py-1 bg-white/5 dark:bg-white/5 border border-white/10 dark:border-white/10 rounded-full text-muted-foreground/70"
|
||||
>
|
||||
{query}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fontes em revisão */}
|
||||
{allSources.length > 0 && (
|
||||
<div className="pl-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
Reviewing sources - {allSources.length}
|
||||
</span>
|
||||
</div>
|
||||
<SourcesCompact sources={allSources} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Search, BookOpen, CheckCircle2, AlertCircle, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrapedContent } from '@/services/webSearch';
|
||||
import { SearchStatus } from '@/hooks/use-web-search';
|
||||
import { useState } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
interface SearchProgressProps {
|
||||
status: SearchStatus;
|
||||
query: string;
|
||||
sources: ScrapedContent[];
|
||||
error: string | null;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
}
|
||||
|
||||
export function SearchProgress({
|
||||
status,
|
||||
query,
|
||||
sources,
|
||||
error,
|
||||
isExpanded: controlledExpanded,
|
||||
onToggleExpand,
|
||||
}: SearchProgressProps) {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
||||
const toggleExpanded = onToggleExpand || (() => setInternalExpanded(!internalExpanded));
|
||||
|
||||
if (status === 'idle') return null;
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'searching':
|
||||
return <Search className="w-3 h-3 text-muted-foreground" />;
|
||||
case 'scraping':
|
||||
return <BookOpen className="w-3 h-3 text-muted-foreground" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-3 h-3 text-muted-foreground" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="w-3 h-3 text-destructive" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'searching':
|
||||
return `Pesquisando: "${query}"`;
|
||||
case 'scraping':
|
||||
return `Lendo ${sources.length || '...'} fonte${sources.length !== 1 ? 's' : ''}`;
|
||||
case 'completed':
|
||||
return `✓ ${sources.length} fonte${sources.length !== 1 ? 's' : ''} consultada${sources.length !== 1 ? 's' : ''}`;
|
||||
case 'error':
|
||||
return `Erro: ${error || 'Falha ao buscar'}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getFaviconUrl = (url: string): string => {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const isActive = status === 'searching' || status === 'scraping';
|
||||
const showExpandButton = status === 'completed' && sources.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"mx-6 mb-2 transition-all",
|
||||
status === 'completed' && "opacity-70 hover:opacity-100",
|
||||
status === 'error' && "text-destructive"
|
||||
)}>
|
||||
<div className="p-2 pl-3 border-l-2 border-muted flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
{isActive ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className="w-3 h-3">
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showExpandButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de fontes expandida */}
|
||||
{isExpanded && sources.length > 0 && (
|
||||
<div className="mt-2 ml-6 pl-3 border-l space-y-2">
|
||||
{sources.map((source, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 p-2 rounded-md bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={getFaviconUrl(source.url)}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded mt-0.5 flex-shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-medium text-primary hover:underline line-clamp-1 flex items-center gap-1"
|
||||
>
|
||||
{source.title || source.url}
|
||||
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
</a>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{source.url}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { SessionSummary } from "@/hooks/use-chat-storage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface SidebarListProps {
|
||||
sessions: SessionSummary[];
|
||||
currentSessionId: string | null;
|
||||
onSelectSession: (id: string) => void;
|
||||
onDeleteSession: (id: string) => void;
|
||||
onNewChat: () => void;
|
||||
onSearch?: (query: string) => void;
|
||||
isSearching?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInMs = now.getTime() - date.getTime();
|
||||
const diffInHours = diffInMs / (1000 * 60 * 60);
|
||||
const diffInDays = diffInMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
// Se foi hoje, mostrar apenas hora
|
||||
if (diffInHours < 24) {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// Se foi ontem
|
||||
if (diffInDays < 2) {
|
||||
return 'Ontem';
|
||||
}
|
||||
|
||||
// Se foi nos últimos 7 dias
|
||||
if (diffInDays < 7) {
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
weekday: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
// Caso contrário, mostrar data completa
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: diffInDays > 365 ? 'numeric' : undefined
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function SidebarList({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
onSelectSession,
|
||||
onDeleteSession,
|
||||
onNewChat,
|
||||
onSearch,
|
||||
isSearching = false
|
||||
}: SidebarListProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const DEBOUNCE_MS = 300;
|
||||
|
||||
useEffect(() => {
|
||||
// Limpar timeout anterior
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Se query vazia, limpar busca imediatamente
|
||||
if (!searchQuery.trim()) {
|
||||
if (onSearch) {
|
||||
onSearch('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: aguardar antes de buscar
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (onSearch && searchQuery.trim().length >= 2) {
|
||||
onSearch(searchQuery.trim());
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, onSearch]);
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('');
|
||||
if (onSearch) {
|
||||
onSearch('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar w-full overflow-x-hidden">
|
||||
{/* Header com busca e botão Nova Conversa */}
|
||||
<div className="p-3 border-b border-sidebar-border flex-shrink-0 space-y-2.5">
|
||||
{/* Campo de busca */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar conversas..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 pr-8 h-9 text-sm"
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
{searchQuery && !isSearching && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Limpar busca"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botão Nova Conversa */}
|
||||
<Button
|
||||
onClick={onNewChat}
|
||||
className="w-full justify-start gap-2 text-sm font-medium h-9"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0" />
|
||||
<span className="truncate">Nova Conversa</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Lista de chats com scroll customizado */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 sidebar-chat-list">
|
||||
<div className="p-2 space-y-1.5">
|
||||
{sessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||
<MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery ? 'Nenhuma conversa encontrada' : 'Nenhuma conversa salva'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
{searchQuery
|
||||
? 'Tente buscar por outro termo'
|
||||
: 'Comece uma nova conversa para começar'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.map((session) => {
|
||||
const isSelected = currentSessionId === session.id;
|
||||
const showTooltip = session.title.length > 30;
|
||||
|
||||
return (
|
||||
<Tooltip key={session.id} delayDuration={300} disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"sidebar-chat-item group relative bg-card border rounded-lg cursor-pointer transition-all duration-200",
|
||||
"hover:shadow-md hover:border-primary/20 hover:-translate-y-0.5",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
isSelected && "border-primary/40 shadow-sm bg-accent/50",
|
||||
"active:scale-[0.98]"
|
||||
)}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelectSession(session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Conteúdo do card */}
|
||||
<div className="sidebar-chat-item-content p-2.5">
|
||||
{/* Ícone/Emoji */}
|
||||
<div className="shrink-0 flex items-center justify-center w-8 h-8 rounded-md bg-muted/50 text-base">
|
||||
{session.emoji || '💬'}
|
||||
</div>
|
||||
|
||||
{/* Texto do card */}
|
||||
<div className="sidebar-chat-item-text">
|
||||
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
|
||||
{session.title}
|
||||
</h3>
|
||||
|
||||
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
|
||||
{formatDate(session.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Botão de deletar - aparece no hover */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-7 w-7 shrink-0 transition-all duration-200",
|
||||
"opacity-0 group-hover:opacity-100",
|
||||
"hover:bg-destructive/10 hover:text-destructive",
|
||||
"active:scale-95"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onDeleteSession(session.id);
|
||||
}}
|
||||
aria-label={`Deletar conversa "${session.title}"`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Indicador de seleção */}
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary rounded-l-lg" />
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<p className="break-words font-medium">{session.title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatDate(session.updated_at)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { ScrapedContent } from '@/services/webSearch';
|
||||
import { useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SourcesCarouselProps {
|
||||
sources: ScrapedContent[];
|
||||
}
|
||||
|
||||
export function SourcesCarousel({ sources }: SourcesCarouselProps) {
|
||||
if (!sources || sources.length === 0) return null;
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
const [scrollLeft, setScrollLeft] = useState(0);
|
||||
const [hasMoved, setHasMoved] = useState(false);
|
||||
|
||||
// Handlers para mouse drag
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
setIsDragging(true);
|
||||
setStartX(e.pageX - scrollContainerRef.current.offsetLeft);
|
||||
setScrollLeft(scrollContainerRef.current.scrollLeft);
|
||||
setHasMoved(false);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || !scrollContainerRef.current) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - scrollContainerRef.current.offsetLeft;
|
||||
const walk = (x - startX) * 1.5; // Velocidade do scroll
|
||||
scrollContainerRef.current.scrollLeft = scrollLeft - walk;
|
||||
|
||||
// Se moveu mais de 3px, marca como movimento (não clique)
|
||||
if (Math.abs(walk) > 3) {
|
||||
setHasMoved(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
// Resetar após um pequeno delay
|
||||
setTimeout(() => setHasMoved(false), 150);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setIsDragging(false);
|
||||
setTimeout(() => setHasMoved(false), 150);
|
||||
};
|
||||
|
||||
// Handlers para touch drag (mobile)
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
setIsDragging(true);
|
||||
setStartX(e.touches[0].pageX - scrollContainerRef.current.offsetLeft);
|
||||
setScrollLeft(scrollContainerRef.current.scrollLeft);
|
||||
setHasMoved(false);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (!isDragging || !scrollContainerRef.current) return;
|
||||
const x = e.touches[0].pageX - scrollContainerRef.current.offsetLeft;
|
||||
const walk = (x - startX) * 1.5;
|
||||
scrollContainerRef.current.scrollLeft = scrollLeft - walk;
|
||||
|
||||
if (Math.abs(walk) > 3) {
|
||||
setHasMoved(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsDragging(false);
|
||||
setTimeout(() => setHasMoved(false), 150);
|
||||
};
|
||||
|
||||
// Prevenir navegação do link durante drag
|
||||
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (hasMoved || isDragging) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto mb-4 mt-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 px-1">
|
||||
Fontes Analisadas
|
||||
</h3>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
"flex gap-3 overflow-x-auto pb-4 scrollbar-hide -mx-4 px-4 sm:mx-0 sm:px-0 mask-fade-sides",
|
||||
"cursor-grab active:cursor-grabbing select-none touch-pan-x",
|
||||
isDragging && "cursor-grabbing"
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{
|
||||
scrollBehavior: isDragging ? 'auto' : 'smooth',
|
||||
WebkitOverflowScrolling: 'touch' // Smooth scrolling no iOS
|
||||
}}
|
||||
>
|
||||
{sources.map((source, idx) => {
|
||||
const hostname = new URL(source.url).hostname.replace('www.', '');
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleLinkClick}
|
||||
className="flex-none w-40 group transition-all hover:-translate-y-1 block"
|
||||
style={{ pointerEvents: (isDragging || hasMoved) ? 'none' : 'auto' }}
|
||||
>
|
||||
<Card className="h-24 p-3 flex flex-col justify-between hover:shadow-md hover:border-primary/20 transition-colors bg-card/50 backdrop-blur-sm cursor-pointer overflow-hidden">
|
||||
<div className="flex items-start justify-between min-h-0 flex-shrink-0">
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded-sm opacity-70 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
onError={(e) => (e.target as HTMLImageElement).style.display = 'none'}
|
||||
/>
|
||||
<ExternalLink className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 ml-1" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 min-w-0 flex-1 overflow-hidden flex flex-col justify-end">
|
||||
<div className="text-xs font-medium leading-tight line-clamp-2 text-card-foreground/90 group-hover:text-primary transition-colors break-words overflow-hidden text-ellipsis">
|
||||
{source.title}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate overflow-hidden text-ellipsis">
|
||||
{hostname}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Source {
|
||||
url: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface SourcesCompactProps {
|
||||
sources: Source[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SourcesCompact({ sources, className }: SourcesCompactProps) {
|
||||
const getFaviconUrl = (url: string): string => {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getDomain = (url: string): string => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0];
|
||||
}
|
||||
};
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3 overflow-x-auto py-2 sources-scrollbar", className)}>
|
||||
{sources.map((source, idx) => {
|
||||
const faviconUrl = getFaviconUrl(source.url);
|
||||
const domain = getDomain(source.url);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 border border-transparent hover:border-muted transition-all flex-shrink-0"
|
||||
>
|
||||
{faviconUrl ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded flex-shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground/60 flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium truncate max-w-[140px] text-foreground/90">
|
||||
{source.title || domain}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{domain}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, Copy, Check, Globe } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { ScrapedContent } from '@/services/webSearch';
|
||||
|
||||
interface SourcesDisplayProps {
|
||||
sources: ScrapedContent[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SourcesDisplay({ sources, className }: SourcesDisplayProps) {
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const copyToClipboard = async (url: string, index: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getFaviconUrl = (url: string): string => {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getDomain = (url: string): string => {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("px-6 py-4 border-t border-muted/50", className)}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="w-4 h-4 text-muted-foreground/60" />
|
||||
<p className="text-xs font-medium text-muted-foreground/80">
|
||||
Fontes consultadas ({sources.length}):
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto py-2 sources-scrollbar">
|
||||
{sources.map((source, idx) => {
|
||||
const faviconUrl = getFaviconUrl(source.url);
|
||||
const domain = getDomain(source.url);
|
||||
const isCopied = copiedIndex === idx;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 border border-transparent hover:border-muted transition-all flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
// Permitir que o botão de copiar funcione sem navegar
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{faviconUrl ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded flex-shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Globe className="w-4 h-4 text-muted-foreground/60 flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium truncate max-w-[140px] text-foreground/90">
|
||||
{source.title || domain}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">
|
||||
{domain}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
copyToClipboard(source.url, idx);
|
||||
}}
|
||||
className="ml-1 p-1 rounded hover:bg-muted/50 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Copiar URL"
|
||||
aria-label="Copiar URL"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface StreamingIndicatorProps {
|
||||
isStreaming: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StreamingIndicator({ isStreaming, className }: StreamingIndicatorProps) {
|
||||
const [dots, setDots] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) {
|
||||
setDots('');
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDots(prev => {
|
||||
if (prev === '...') return '';
|
||||
return prev + '.';
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreaming]);
|
||||
|
||||
if (!isStreaming) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-xs text-muted-foreground px-6 py-2", className)}>
|
||||
<div className="relative">
|
||||
<Sparkles className="w-3 h-3 text-primary animate-pulse" />
|
||||
<div className="absolute inset-0 bg-primary/20 rounded-full animate-ping" />
|
||||
</div>
|
||||
<span>Streaming{dots}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Trash2, HardDrive, Cpu, Database, Upload, Globe } from "lucide-react";
|
||||
import { useSystemMonitor } from "@/hooks/use-system-monitor";
|
||||
import { useLocalModels } from "@/hooks/use-local-models";
|
||||
import { useState } from "react";
|
||||
import { ModelDownloadDialog } from "./model-download-dialog";
|
||||
|
||||
export function SystemPanel() {
|
||||
const stats = useSystemMonitor();
|
||||
const { models, deleteModel, refresh } = useLocalModels();
|
||||
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
||||
|
||||
const ramUsagePercent = stats
|
||||
? (stats.memory_used / stats.memory_total) * 100
|
||||
: 0;
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
return `${gb.toFixed(1)} GB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 space-y-6">
|
||||
{/* Hardware Stats */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Monitoramento
|
||||
</h3>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" /> Processador
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>Uso</span>
|
||||
<span>{stats?.cpu_usage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={stats?.cpu_usage || 0} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4" /> Memória RAM
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>{stats ? formatBytes(stats.memory_used) : '-'}</span>
|
||||
<span>{stats ? formatBytes(stats.memory_total) : '-'}</span>
|
||||
</div>
|
||||
<Progress value={ramUsagePercent} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Models List */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Modelos Instalados
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDownloadDialog(true)}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
<Upload className="w-3 h-3 mr-1" />
|
||||
Adicionar
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
|
||||
Atualizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{models.map((model) => {
|
||||
// Tentar detectar se é modelo local/GGUF (heurística: modelos GGUF geralmente têm nomes específicos)
|
||||
// Por enquanto, vamos mostrar todos como disponíveis
|
||||
const isLocal = model.name.toLowerCase().includes('gguf') ||
|
||||
model.name.toLowerCase().endsWith('.gguf');
|
||||
|
||||
return (
|
||||
<Card key={model.id} className="overflow-hidden hover:bg-muted/50 transition-colors">
|
||||
<CardContent className="p-3 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="font-medium text-sm truncate" title={model.name}>
|
||||
{model.name}
|
||||
</div>
|
||||
{isLocal && (
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
|
||||
<Upload className="w-2.5 h-2.5 mr-1" />
|
||||
Local
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Database className="w-3 h-3" />
|
||||
{model.size}
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 flex-shrink-0"
|
||||
onClick={async () => {
|
||||
if (confirm(`Tem certeza que deseja remover o modelo "${model.name}"?`)) {
|
||||
try {
|
||||
await deleteModel(model.name);
|
||||
} catch (error) {
|
||||
console.error('Erro ao remover modelo:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remover modelo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{models.length === 0 && (
|
||||
<div className="text-center text-sm text-muted-foreground py-8 space-y-2">
|
||||
<p>Nenhum modelo encontrado.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDownloadDialog(true)}
|
||||
className="mt-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Instalar Modelo
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModelDownloadDialog
|
||||
open={showDownloadDialog}
|
||||
onOpenChange={setShowDownloadDialog}
|
||||
onSuccess={(modelName) => {
|
||||
refresh();
|
||||
setShowDownloadDialog(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Search, BookOpen, Brain, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type ThinkingStep =
|
||||
| 'analyzing'
|
||||
| 'planning'
|
||||
| 'searching'
|
||||
| 'reading'
|
||||
| 'validating'
|
||||
| 'formulating'
|
||||
| 'complete';
|
||||
|
||||
interface ThinkingIndicatorProps {
|
||||
currentStep: ThinkingStep;
|
||||
searchQuery?: string;
|
||||
sourcesRead?: number;
|
||||
totalSources?: number;
|
||||
planSize?: number;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function ThinkingIndicator({
|
||||
currentStep,
|
||||
searchQuery,
|
||||
sourcesRead = 0,
|
||||
totalSources = 0,
|
||||
planSize = 0,
|
||||
}: ThinkingIndicatorProps) {
|
||||
const steps: Array<{
|
||||
key: ThinkingStep;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}> = [
|
||||
{
|
||||
key: 'analyzing',
|
||||
label: 'Analisando...',
|
||||
icon: <Brain className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'planning',
|
||||
label: 'Planejando pesquisa...',
|
||||
icon: <Brain className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'searching',
|
||||
label: planSize > 0 ? `Pesquisando (${planSize} tópicos)...` : 'Pesquisando na web...',
|
||||
icon: <Search className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'reading',
|
||||
label: 'Lendo conteúdo...',
|
||||
icon: <BookOpen className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'validating',
|
||||
label: 'Validando dados...',
|
||||
icon: <CheckCircle2 className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'formulating',
|
||||
label: 'Formulando resposta...',
|
||||
icon: <Brain className="w-3 h-3" />,
|
||||
},
|
||||
];
|
||||
|
||||
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
|
||||
const isComplete = currentStep === 'complete';
|
||||
|
||||
if (isComplete) {
|
||||
return null; // Não mostrar quando completo
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 py-4 border-b border-muted/50 bg-muted/20">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
|
||||
const isActive = idx === currentStepIndex;
|
||||
const isPast = idx < currentStepIndex;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-5 h-5 rounded-full transition-colors",
|
||||
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
|
||||
isActive && "bg-blue-500/20 text-blue-600 dark:text-blue-400 animate-pulse",
|
||||
!isActive && !isPast && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPast ? (
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
) : isActive ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
step.icon
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
isActive && "text-foreground font-medium",
|
||||
isPast && "text-muted-foreground line-through opacity-60"
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
{idx < steps.length - 1 && (
|
||||
<div className="w-8 h-px bg-muted mx-1" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Search, BookOpen, CheckCircle2, AlertCircle, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrapedContent } from '@/services/webSearch';
|
||||
import { SearchStatus } from '@/hooks/use-web-search';
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface WebSearchStatusProps {
|
||||
status: SearchStatus;
|
||||
query: string;
|
||||
sources: ScrapedContent[];
|
||||
error: string | null;
|
||||
onViewSources?: () => void;
|
||||
}
|
||||
|
||||
export function WebSearchStatus({
|
||||
status,
|
||||
query,
|
||||
sources,
|
||||
error,
|
||||
}: WebSearchStatusProps) {
|
||||
const [isSourcesOpen, setIsSourcesOpen] = useState(false);
|
||||
|
||||
if (status === 'idle') return null;
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'searching':
|
||||
return <Search className="w-4 h-4 animate-pulse" />;
|
||||
case 'scraping':
|
||||
return <BookOpen className="w-4 h-4 animate-pulse" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case 'searching':
|
||||
return `🔍 Pesquisando por: "${query}"...`;
|
||||
case 'scraping':
|
||||
return `📖 Lendo ${sources.length || '...'} fonte${sources.length !== 1 ? 's' : ''}...`;
|
||||
case 'completed':
|
||||
return `✅ ${sources.length} fonte${sources.length !== 1 ? 's' : ''} encontrada${sources.length !== 1 ? 's' : ''}`;
|
||||
case 'error':
|
||||
return `❌ Erro: ${error || 'Falha ao buscar'}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getFaviconUrl = (url: string): string => {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={cn(
|
||||
"mx-4 mb-2 border-l-4",
|
||||
status === 'completed' && "border-l-green-500",
|
||||
status === 'error' && "border-l-red-500",
|
||||
(status === 'searching' || status === 'scraping') && "border-l-blue-500"
|
||||
)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
{status === 'searching' || status === 'scraping' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
) : (
|
||||
getStatusIcon()
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status === 'completed' && sources.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Favicons das fontes */}
|
||||
<div className="flex items-center gap-1">
|
||||
{sources.slice(0, 3).map((source, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={getFaviconUrl(source.url)}
|
||||
alt=""
|
||||
className="w-4 h-4 rounded"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{sources.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">+{sources.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isSourcesOpen} onOpenChange={setIsSourcesOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||
Ver Fontes
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fontes Encontradas</DialogTitle>
|
||||
<DialogDescription>
|
||||
{sources.length} fonte{sources.length !== 1 ? 's' : ''} analisada{sources.length !== 1 ? 's' : ''} para: "{query}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-4 pr-2">
|
||||
{sources.map((source, idx) => (
|
||||
<Card key={idx} className="border">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm mb-1 line-clamp-2">
|
||||
{source.title}
|
||||
</h4>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 truncate"
|
||||
>
|
||||
{source.url}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prose dark:prose-invert prose-sm max-w-none max-h-[300px] overflow-y-auto">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{source.markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, Cpu } from "lucide-react";
|
||||
|
||||
export function Hero({ onStart }: { onStart: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4 space-y-6 animate-in fade-in zoom-in duration-500">
|
||||
<div className="p-4 rounded-full bg-primary/10 animate-pulse">
|
||||
<Cpu className="w-12 h-12 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-6xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60">
|
||||
OllaHub
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-muted-foreground max-w-[600px]">
|
||||
Sua interface moderna e poderosa para interagir com Ollama e Modelos de Linguagem Locais.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Button size="lg" onClick={onStart} className="group text-lg px-8 h-12">
|
||||
Iniciar Chat
|
||||
<ArrowRight className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RefreshCw, Copy, Check, Play, Loader2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
|
||||
export function StoppedCard({ onCheckAgain }: { onCheckAgain: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const command = "ollama serve";
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
setStarting(true);
|
||||
try {
|
||||
await invoke('start_ollama_server');
|
||||
// Wait a bit for the server to start before checking again
|
||||
setTimeout(() => {
|
||||
onCheckAgain();
|
||||
setStarting(false);
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error("Failed to start ollama:", error);
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto border-yellow-500/50 bg-yellow-50/10 dark:bg-yellow-900/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-yellow-600 dark:text-yellow-500">Ollama está parado</CardTitle>
|
||||
<CardDescription>
|
||||
O Ollama está instalado mas não está rodando. Você pode iniciá-lo automaticamente ou executar o comando manualmente.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
className="w-full bg-yellow-600 hover:bg-yellow-700 text-white"
|
||||
disabled={starting}
|
||||
>
|
||||
{starting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Iniciando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Iniciar Ollama Automaticamente
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-yellow-500/20" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Ou manualmente</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="bg-muted p-3 rounded-md font-mono text-sm flex-1 border">
|
||||
{command}
|
||||
</div>
|
||||
<Button size="icon" variant="outline" onClick={handleCopy}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={onCheckAgain} className="w-full" variant="secondary">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Verificar Novamente
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Download, Loader2, AlertCircle, CheckCircle2, Play } from "lucide-react"
|
||||
import { useOllamaDownload } from "@/hooks/use-ollama-download"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { useOperatingSystem } from "@/hooks/use-operating-system"
|
||||
import type { OS } from "@/lib/download-utils"
|
||||
|
||||
interface InstallModalProps {
|
||||
open: boolean;
|
||||
onCheckAgain: () => void;
|
||||
}
|
||||
|
||||
export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
const { os: detectedOS } = useOperatingSystem();
|
||||
const [activeTab, setActiveTab] = useState<OS>('mac');
|
||||
const downloadState = useOllamaDownload();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Detectar OS e definir aba ativa quando modal abrir
|
||||
useEffect(() => {
|
||||
if (open && detectedOS) {
|
||||
setActiveTab(detectedOS);
|
||||
}
|
||||
}, [open, detectedOS]);
|
||||
|
||||
// Resetar estado quando o modal abrir
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
downloadState.reset();
|
||||
// Verificar se já existe download para a tab ativa
|
||||
downloadState.checkExistingDownload(activeTab);
|
||||
} else {
|
||||
// Parar verificação automática quando modal fechar
|
||||
downloadState.stopAutoCheck();
|
||||
}
|
||||
}, [open, activeTab]);
|
||||
|
||||
// Verificar download existente ao mudar de tab
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
downloadState.checkExistingDownload(activeTab);
|
||||
}
|
||||
}, [activeTab, open]);
|
||||
|
||||
// Escutar evento de Ollama instalado
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleOllamaInstalled = () => {
|
||||
// Ollama foi encontrado, verificar novamente para atualizar o estado
|
||||
onCheckAgain();
|
||||
};
|
||||
|
||||
window.addEventListener('ollama-installed', handleOllamaInstalled);
|
||||
return () => {
|
||||
window.removeEventListener('ollama-installed', handleOllamaInstalled);
|
||||
};
|
||||
}, [open, onCheckAgain]);
|
||||
|
||||
// Mostrar toast quando houver erro
|
||||
useEffect(() => {
|
||||
if (downloadState.downloadError) {
|
||||
toast({
|
||||
title: 'Erro no download',
|
||||
description: downloadState.downloadError,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [downloadState.downloadError, toast]);
|
||||
|
||||
const handleDownload = async (os: OS) => {
|
||||
await downloadState.handleDownload(os);
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
await downloadState.handleInstall();
|
||||
toast({
|
||||
title: 'Instalador iniciado',
|
||||
description: 'O instalador foi aberto. Siga as instruções na tela.',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Erro ao executar instalador',
|
||||
description: error instanceof Error ? error.message : 'Erro desconhecido',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const DownloadButton = ({ os, label }: { os: OS; label: string }) => {
|
||||
const isActive = activeTab === os;
|
||||
const isDownloading = downloadState.isDownloading && isActive;
|
||||
const hasDownloaded = downloadState.filePath !== null && isActive;
|
||||
|
||||
if (hasDownloaded && !isDownloading) {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
onClick={handleInstall}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Instalar Ollama
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => handleDownload(os)}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{downloadState.downloadStatus || 'Baixando...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadProgress = ({ os }: { os: OS }) => {
|
||||
const isActive = activeTab === os;
|
||||
const isDownloading = downloadState.isDownloading && isActive;
|
||||
const hasDownloaded = downloadState.filePath !== null && isActive;
|
||||
|
||||
if (!isDownloading && !hasDownloaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{isDownloading && (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{downloadState.downloadStatus || 'Baixando...'}
|
||||
</span>
|
||||
<span className="font-medium">{downloadState.downloadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={downloadState.downloadProgress} className="h-2" />
|
||||
</>
|
||||
)}
|
||||
{hasDownloaded && !isDownloading && (
|
||||
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
||||
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
||||
<div className="text-sm flex-1">
|
||||
<p className="font-medium text-green-900 dark:text-green-100">
|
||||
Download concluído
|
||||
</p>
|
||||
<p className="text-green-700 dark:text-green-300">
|
||||
Clique em "Instalar Ollama" para iniciar a instalação.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ollama não encontrado</DialogTitle>
|
||||
<DialogDescription>
|
||||
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
|
||||
Baixe e instale o Ollama usando os passos abaixo.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as OS)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="mac">macOS</TabsTrigger>
|
||||
<TabsTrigger value="linux">Linux</TabsTrigger>
|
||||
<TabsTrigger value="windows">Windows</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="mac" className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">1. Download e Instalação</h3>
|
||||
<DownloadButton os="mac" label="Baixar para macOS" />
|
||||
<DownloadProgress os="mac" />
|
||||
</div>
|
||||
{downloadState.downloadError && activeTab === 'mac' && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
|
||||
<p className="text-red-700 dark:text-red-300">
|
||||
{downloadState.downloadError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>Após instalar, o Ollama será iniciado automaticamente. Se necessário, abra o terminal e digite <code className="bg-muted px-1 rounded">ollama serve</code>.</p>
|
||||
<p className="font-medium text-foreground">
|
||||
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="linux" className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Instalação via Terminal</h3>
|
||||
<div className="bg-muted p-4 rounded-md relative group">
|
||||
<code className="text-sm break-all">curl -fsSL https://ollama.com/install.sh | sh</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Ou baixar e instalar via interface</h3>
|
||||
<DownloadButton os="linux" label="Baixar Script de Instalação" />
|
||||
<DownloadProgress os="linux" />
|
||||
</div>
|
||||
{downloadState.downloadError && activeTab === 'linux' && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
|
||||
<p className="text-red-700 dark:text-red-300">
|
||||
{downloadState.downloadError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Comando Manual</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Ou consulte <a href="https://ollama.com/download/linux" className="underline text-primary" target="_blank" rel="noopener noreferrer">ollama.com/download/linux</a> para instruções manuais.
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="windows" className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">1. Download do Instalador</h3>
|
||||
<DownloadButton os="windows" label="Baixar para Windows" />
|
||||
<DownloadProgress os="windows" />
|
||||
</div>
|
||||
{downloadState.downloadError && activeTab === 'windows' && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
|
||||
<p className="text-red-700 dark:text-red-300">
|
||||
{downloadState.downloadError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>Após clicar em "Instalar Ollama", o instalador será aberto. Siga as instruções na tela para completar a instalação.</p>
|
||||
<p className="font-medium text-foreground">
|
||||
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Status de verificação automática */}
|
||||
{downloadState.isChecking && (
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md mt-4">
|
||||
<Loader2 className="h-4 w-4 mt-0.5 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
<div className="text-sm flex-1">
|
||||
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||
Verificando instalação do Ollama...
|
||||
</p>
|
||||
<p className="text-blue-700 dark:text-blue-300">
|
||||
{downloadState.checkStatus || 'Aguardando instalação ser concluída...'}
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||
Esta verificação continuará em segundo plano até encontrar o Ollama.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Chamar verificação completa (instalação + execução)
|
||||
onCheckAgain();
|
||||
}}
|
||||
>
|
||||
Verificar Novamente
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Heart } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function WelcomeNotification() {
|
||||
const [show, setShow] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// Verificar se já foi mostrado
|
||||
const welcomeShown = localStorage.getItem('ollahub_welcome_shown');
|
||||
if (!welcomeShown) {
|
||||
setShow(true);
|
||||
// Marcar como mostrado
|
||||
localStorage.setItem('ollahub_welcome_shown', 'true');
|
||||
// Auto-fechar após 5 segundos
|
||||
const timer = setTimeout(() => {
|
||||
setShow(false);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!mounted || !show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-4 left-1/2 -translate-x-1/2 z-50 w-full max-w-md",
|
||||
"animate-in slide-in-from-top-5 fade-in duration-500"
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary text-primary-foreground rounded-lg shadow-lg p-4 mx-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Heart className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-sm mb-1">
|
||||
Obrigado por usar o OllaHub! 🎉
|
||||
</h3>
|
||||
<p className="text-xs text-primary-foreground/90">
|
||||
Estamos felizes em tê-lo conosco. Comece a explorar e aproveite sua experiência!
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-primary-foreground hover:bg-primary-foreground/20 flex-shrink-0"
|
||||
onClick={() => setShow(false)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import { usePythonBootstrap } from '@/hooks/use-python-bootstrap';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Loader2, XCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
checking: 'Verificando',
|
||||
downloading: 'Baixando',
|
||||
extracting: 'Extraindo',
|
||||
creating_venv: 'Criando ambiente virtual',
|
||||
installing_pip: 'Instalando pip',
|
||||
copying_scripts: 'Copiando scripts',
|
||||
installing_deps: 'Instalando dependências',
|
||||
validating: 'Validando',
|
||||
done: 'Concluído',
|
||||
};
|
||||
|
||||
export function PythonBootstrapProgress() {
|
||||
const bootstrap = usePythonBootstrap();
|
||||
|
||||
// Se estiver completo e sem erro, não mostrar nada
|
||||
if (bootstrap.isComplete && !bootstrap.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Se houver erro, mostrar alerta
|
||||
if (bootstrap.error) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md mx-4">
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<XCircle className="h-5 w-5" />
|
||||
Erro no Bootstrap do Python
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
O aplicativo não pode funcionar sem o runtime Python
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md bg-destructive/10 p-3">
|
||||
<p className="text-sm text-destructive font-medium">{bootstrap.error}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Por favor, reinicie o aplicativo ou verifique os logs para mais detalhes.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mostrar progresso durante bootstrap
|
||||
const stageLabel = STAGE_LABELS[bootstrap.stage] || bootstrap.stage;
|
||||
const progressValue = bootstrap.progress ?? 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md mx-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
Inicializando Python Runtime
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configurando ambiente Python para scraping web
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{stageLabel}</span>
|
||||
{bootstrap.progress !== null && (
|
||||
<span className="text-muted-foreground">{Math.round(progressValue * 100)}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bootstrap.progress !== null && (
|
||||
<Progress value={progressValue * 100} className="h-2" />
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{bootstrap.message}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Loader2, Search, FileText } from "lucide-react";
|
||||
import { useWebSearch } from "@/hooks/use-web-search";
|
||||
|
||||
export function ScrapingProgressIndicator() {
|
||||
const webSearch = useWebSearch();
|
||||
|
||||
// Não mostrar se idle ou completed
|
||||
if (webSearch.status === 'idle' || webSearch.status === 'completed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="fixed bottom-24 right-6 bg-card border border-primary/20 rounded-lg p-4 shadow-lg backdrop-blur-sm z-50 min-w-[280px]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon animado */}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{webSearch.status === 'searching' && (
|
||||
<Search className="w-5 h-5 text-primary animate-pulse" />
|
||||
)}
|
||||
{webSearch.status === 'scraping' && (
|
||||
<FileText className="w-5 h-5 text-primary animate-pulse" />
|
||||
)}
|
||||
{webSearch.status === 'error' && (
|
||||
<div className="w-5 h-5 rounded-full bg-destructive/20 flex items-center justify-center">
|
||||
<span className="text-destructive text-xs font-bold">!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
<p className="text-sm font-medium">
|
||||
{webSearch.status === 'searching' && 'Buscando fontes...'}
|
||||
{webSearch.status === 'scraping' && `Analisando fontes (${webSearch.scrapedSources.length})...`}
|
||||
{webSearch.status === 'error' && 'Erro na busca'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mostrar query atual se disponível */}
|
||||
{webSearch.currentQuery && webSearch.status !== 'error' && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||
{webSearch.currentQuery}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Mostrar erro se houver */}
|
||||
{webSearch.error && webSearch.status === 'error' && (
|
||||
<p className="text-xs text-destructive line-clamp-2">
|
||||
{webSearch.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Barra de progresso simulada */}
|
||||
{webSearch.status !== 'error' && (
|
||||
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-primary"
|
||||
initial={{ width: "0%" }}
|
||||
animate={{
|
||||
width: webSearch.status === 'searching' ? "40%" : "80%"
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Monitor, Thermometer, Zap, Fan, Cpu, HardDrive, Activity } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface GpuStats {
|
||||
id: string;
|
||||
name: string;
|
||||
vendor: string | null;
|
||||
vram_used_mb: number | null;
|
||||
vram_total_mb: number | null;
|
||||
vram_percent: number | null;
|
||||
compute_usage_percent: number | null;
|
||||
graphics_usage_percent: number | null;
|
||||
overall_usage_percent: number | null;
|
||||
temperature_celsius: number | null;
|
||||
temperature_max_celsius: number | null;
|
||||
power_watts: number | null;
|
||||
power_max_watts: number | null;
|
||||
fan_speed_rpm: number | null;
|
||||
fan_speed_percent: number | null;
|
||||
processes_count: number | null;
|
||||
driver_version: string | null;
|
||||
api: string | null;
|
||||
}
|
||||
|
||||
interface GpuDashboardProps {
|
||||
gpuId?: string;
|
||||
pollInterval?: number; // em milissegundos, padrão 2000ms
|
||||
}
|
||||
|
||||
export function GpuDashboard({ gpuId, pollInterval = 2000 }: GpuDashboardProps) {
|
||||
const [stats, setStats] = useState<GpuStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const data = await invoke<GpuStats | null>('get_gpu_stats', { gpuId: gpuId || null });
|
||||
setStats(data);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to load GPU stats:', err);
|
||||
setError(err instanceof Error ? err.message : 'Erro ao carregar estatísticas da GPU');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadStats();
|
||||
const interval = setInterval(loadStats, pollInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [gpuId, pollInterval]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Carregando estatísticas da GPU...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
{error || 'GPU não detectada ou sem suporte para monitoramento detalhado'}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const formatBytes = (mb: number | null): string => {
|
||||
if (!mb) return '-';
|
||||
if (mb < 1024) return `${mb} MB`;
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const getStatusColor = (value: number | null, thresholds: { good: number; warning: number }): string => {
|
||||
if (!value) return 'bg-muted';
|
||||
if (value < thresholds.good) return 'bg-green-500';
|
||||
if (value < thresholds.warning) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
const getTemperatureColor = (temp: number | null): string => {
|
||||
if (!temp) return 'text-muted-foreground';
|
||||
if (temp < 60) return 'text-green-500';
|
||||
if (temp < 80) return 'text-yellow-500';
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Card Principal da GPU */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
GPU
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{stats.vendor && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{stats.vendor}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.api && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{stats.api}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 truncate" title={stats.name}>
|
||||
{stats.name}
|
||||
</p>
|
||||
{stats.driver_version && (
|
||||
<p className="text-xs text-muted-foreground">Driver: {stats.driver_version}</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* VRAM */}
|
||||
{stats.vram_total_mb && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
Memória VRAM
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatBytes(stats.vram_used_mb)} / {formatBytes(stats.vram_total_mb)}
|
||||
{stats.vram_percent && ` (${stats.vram_percent.toFixed(1)}%)`}
|
||||
</span>
|
||||
</div>
|
||||
{stats.vram_percent !== null && (
|
||||
<Progress
|
||||
value={stats.vram_percent}
|
||||
className="h-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uso de Processamento */}
|
||||
{stats.overall_usage_percent !== null && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Cpu className="w-3 h-3" />
|
||||
Uso de Processamento
|
||||
</span>
|
||||
<span className="font-medium">{stats.overall_usage_percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={stats.overall_usage_percent}
|
||||
className="h-2"
|
||||
/>
|
||||
{stats.compute_usage_percent !== null && stats.graphics_usage_percent !== null && (
|
||||
<div className="flex gap-2 text-[10px] text-muted-foreground">
|
||||
<span>Compute: {stats.compute_usage_percent.toFixed(1)}%</span>
|
||||
<span>Graphics: {stats.graphics_usage_percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid de Métricas */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||
{/* Temperatura */}
|
||||
{stats.temperature_celsius !== null && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Thermometer className="w-3 h-3" />
|
||||
Temperatura
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-lg font-semibold ${getTemperatureColor(stats.temperature_celsius)}`}>
|
||||
{stats.temperature_celsius.toFixed(0)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">°C</span>
|
||||
{stats.temperature_max_celsius && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
/ {stats.temperature_max_celsius.toFixed(0)}°C
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Energia */}
|
||||
{stats.power_watts !== null && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Zap className="w-3 h-3" />
|
||||
Energia
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-semibold">
|
||||
{stats.power_watts.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">W</span>
|
||||
{stats.power_max_watts && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
/ {stats.power_max_watts.toFixed(1)}W
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ventilador */}
|
||||
{(stats.fan_speed_percent !== null || stats.fan_speed_rpm !== null) && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Fan className="w-3 h-3" />
|
||||
Ventilador
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
{stats.fan_speed_percent !== null && (
|
||||
<>
|
||||
<span className="text-lg font-semibold">
|
||||
{stats.fan_speed_percent.toFixed(0)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</>
|
||||
)}
|
||||
{stats.fan_speed_rpm !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
({stats.fan_speed_rpm} RPM)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processos */}
|
||||
{stats.processes_count !== null && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Activity className="w-3 h-3" />
|
||||
Processos
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-semibold">
|
||||
{stats.processes_count}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{stats.processes_count === 1 ? 'processo' : 'processos'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Cpu, MemoryStick, HardDrive, Activity, Clock, Monitor } from 'lucide-react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { GpuDashboard } from './GpuDashboard';
|
||||
|
||||
interface SystemStats {
|
||||
cpu_usage: number;
|
||||
ram_used: number;
|
||||
ram_total: number;
|
||||
ram_percent: number;
|
||||
gpu_name: string | null;
|
||||
uptime: number;
|
||||
processes_count: number;
|
||||
cpu_name: string;
|
||||
}
|
||||
|
||||
export function HardwareDashboard() {
|
||||
const [stats, setStats] = useState<SystemStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const data = await invoke<SystemStats>('get_system_stats');
|
||||
setStats(data);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load system stats:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadStats();
|
||||
const interval = setInterval(loadStats, 1000); // Atualiza a cada 1 segundo
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (isLoading || !stats) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Carregando estatísticas do sistema...
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
};
|
||||
|
||||
const getCpuColor = (usage: number): string => {
|
||||
if (usage < 50) return 'bg-green-500';
|
||||
if (usage < 80) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
const getRamColor = (percent: number): string => {
|
||||
if (percent < 60) return 'bg-blue-500';
|
||||
if (percent < 85) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* CPU Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
CPU
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{stats.cpu_usage.toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Progress
|
||||
value={stats.cpu_usage}
|
||||
className="h-2"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="truncate">{stats.cpu_name}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* RAM Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<MemoryStick className="w-4 h-4" />
|
||||
Memória RAM
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{stats.ram_percent.toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Progress
|
||||
value={stats.ram_percent}
|
||||
className="h-2"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{formatBytes(stats.ram_used)} / {formatBytes(stats.ram_total)}</span>
|
||||
<span>{formatBytes(stats.ram_total - stats.ram_used)} livre</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Info Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
Sistema
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="w-3 h-3" />
|
||||
Uptime
|
||||
</span>
|
||||
<span className="font-medium">{formatUptime(stats.uptime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Processos</span>
|
||||
<span className="font-medium">{stats.processes_count.toLocaleString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Visual RAM Usage */}
|
||||
<Card className="md:col-span-2 lg:col-span-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
Uso de Memória
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Usado</span>
|
||||
<span className="font-medium">{stats.ram_percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="relative h-24 bg-muted rounded-lg overflow-hidden">
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 ${getRamColor(stats.ram_percent)} transition-all duration-300`}
|
||||
style={{ height: `${stats.ram_percent}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs font-medium">
|
||||
{formatBytes(stats.ram_used)} / {formatBytes(stats.ram_total)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* GPU Dashboard - Substitui o card simples */}
|
||||
<GpuDashboard pollInterval={2000} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState, KeyboardEvent } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface DomainTagsInputProps {
|
||||
domains: string[];
|
||||
onAdd: (domain: string) => void;
|
||||
onRemove: (domain: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function DomainTagsInput({
|
||||
domains,
|
||||
onAdd,
|
||||
onRemove,
|
||||
placeholder = 'Digite um domínio e pressione Enter...',
|
||||
}: DomainTagsInputProps) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && inputValue.trim()) {
|
||||
e.preventDefault();
|
||||
onAdd(inputValue.trim());
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeDomain = (domain: string): string => {
|
||||
return domain
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/$/, '')
|
||||
.split('/')[0]; // Remove path
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Domínios Bloqueados</Label>
|
||||
<div className="flex flex-wrap gap-2 p-3 border rounded-md min-h-[50px] bg-background">
|
||||
{domains.map((domain) => (
|
||||
<Badge
|
||||
key={domain}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 pr-1"
|
||||
>
|
||||
{domain}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(domain)}
|
||||
className="ml-1 rounded-full hover:bg-destructive/20 p-0.5 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={domains.length === 0 ? placeholder : ''}
|
||||
className="flex-1 min-w-[200px] border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-auto p-0"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Digite um domínio (ex: youtube.com) e pressione Enter para adicionar à lista negra.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Check, Cpu, Monitor, Zap, Laptop } from "lucide-react";
|
||||
import { GpuInfo } from "@/lib/recommendation";
|
||||
import { useSettingsStore } from "@/store/settings-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GpuSelectorProps {
|
||||
gpus: GpuInfo[];
|
||||
}
|
||||
|
||||
export function GpuSelector({ gpus }: GpuSelectorProps) {
|
||||
const selectedGpu = useSettingsStore((state) => state.selectedGpu);
|
||||
const setSelectedGpu = useSettingsStore((state) => state.setSelectedGpu);
|
||||
|
||||
// Se não há GPUs ou apenas uma, não mostrar seletor
|
||||
if (gpus.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Se há apenas uma GPU, selecionar automaticamente
|
||||
if (gpus.length === 1 && !selectedGpu) {
|
||||
setSelectedGpu(gpus[0].id);
|
||||
}
|
||||
|
||||
// Selecionar primeira GPU por padrão se nenhuma estiver selecionada
|
||||
useEffect(() => {
|
||||
if (!selectedGpu && gpus.length > 0) {
|
||||
setSelectedGpu(gpus[0].id);
|
||||
}
|
||||
}, [selectedGpu, gpus, setSelectedGpu]);
|
||||
|
||||
const getVendorIcon = (vendor: string | null) => {
|
||||
const iconClass = "h-5 w-5";
|
||||
switch (vendor?.toUpperCase()) {
|
||||
case 'NVIDIA':
|
||||
return <Zap className={cn(iconClass, "text-green-500")} />;
|
||||
case 'AMD':
|
||||
return <Zap className={cn(iconClass, "text-red-500")} />;
|
||||
case 'INTEL':
|
||||
return <Cpu className={cn(iconClass, "text-blue-500")} />;
|
||||
case 'APPLE':
|
||||
return <Monitor className={cn(iconClass, "text-gray-500")} />;
|
||||
default:
|
||||
return <Monitor className={cn(iconClass, "text-muted-foreground")} />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatMemory = (memoryMb: number | null) => {
|
||||
if (!memoryMb) return null;
|
||||
if (memoryMb >= 1024) {
|
||||
return `${(memoryMb / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
return `${memoryMb} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto bg-muted/30 animate-in fade-in slide-in-from-bottom-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Monitor className="text-primary" />
|
||||
Placas de Vídeo Detectadas
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Selecione qual GPU você deseja usar para processamento de IA
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{gpus.map((gpu) => {
|
||||
const isSelected = selectedGpu === gpu.id;
|
||||
const memoryStr = formatMemory(gpu.memory_mb);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={gpu.id}
|
||||
onClick={() => setSelectedGpu(gpu.id)}
|
||||
className={cn(
|
||||
"relative p-4 rounded-lg border-2 transition-all text-left",
|
||||
"hover:border-primary/50 hover:bg-accent/50",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-border bg-background"
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="h-4 w-4 text-primary-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="mt-0.5">
|
||||
{getVendorIcon(gpu.vendor)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-sm truncate">{gpu.name}</h3>
|
||||
{gpu.vendor && (
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{gpu.vendor}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{memoryStr && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Cpu className="h-3 w-3" />
|
||||
<span>{memoryStr} VRAM</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!memoryStr && gpu.vendor && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{gpu.vendor === 'Intel' ? 'GPU Integrada' : 'GPU Dedicada'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{gpus.length === 1 && (
|
||||
<p className="text-sm text-muted-foreground mt-4 text-center">
|
||||
Apenas uma GPU foi detectada e será usada automaticamente.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Cpu, HardDrive, Laptop } from "lucide-react";
|
||||
import { SystemSpecs } from "@/lib/recommendation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface HardwareScanProps {
|
||||
specs: SystemSpecs | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function HardwareScan({ specs, loading }: HardwareScanProps) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
const interval = setInterval(() => {
|
||||
setProgress((prev) => (prev >= 90 ? 90 : prev + 10));
|
||||
}, 150);
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
setProgress(100);
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto border-primary/20">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="animate-pulse text-primary" />
|
||||
Analisando Hardware...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Verificando memória e processador para recomendação ideal.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!specs) return null;
|
||||
|
||||
const ramGB = (specs.total_memory / (1024 * 1024 * 1024)).toFixed(1);
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto bg-muted/30 animate-in fade-in slide-in-from-bottom-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Laptop className="text-primary" />
|
||||
Sistema Detectado
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col items-center p-3 bg-background rounded-lg border">
|
||||
<HardDrive className="w-6 h-6 mb-2 text-muted-foreground" />
|
||||
<span className="text-xl font-bold">{ramGB} GB</span>
|
||||
<span className="text-xs text-muted-foreground">RAM Total</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-3 bg-background rounded-lg border">
|
||||
<Cpu className="w-6 h-6 mb-2 text-muted-foreground" />
|
||||
<span className="text-xl font-bold">{specs.cpu_count}</span>
|
||||
<span className="text-xs text-muted-foreground">Núcleos CPU</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Check, Download, Loader2, Play } from "lucide-react";
|
||||
import { RECOMMENDED_MODELS, ModelRecommendation } from "@/lib/recommendation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
interface ModelSelectorProps {
|
||||
recommendation: ModelRecommendation;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
interface DownloadInfo {
|
||||
status: string;
|
||||
percent?: number;
|
||||
downloaded?: string;
|
||||
total?: string;
|
||||
speed?: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps) {
|
||||
const [selectedModelId, setSelectedModelId] = useState(recommendation.modelId);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [downloadStatus, setDownloadStatus] = useState("");
|
||||
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
|
||||
|
||||
const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0];
|
||||
|
||||
useEffect(() => {
|
||||
checkInstallation(selectedModelId);
|
||||
}, [selectedModelId]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen<string>('download-progress', (event) => {
|
||||
const payload = event.payload;
|
||||
|
||||
// Tentar parsear como JSON estruturado
|
||||
let info: DownloadInfo | null = null;
|
||||
try {
|
||||
info = JSON.parse(payload) as DownloadInfo;
|
||||
} catch {
|
||||
// Fallback: tratar como string raw
|
||||
info = {
|
||||
status: "downloading",
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (info) {
|
||||
setDownloadInfo(info);
|
||||
|
||||
// Atualizar status text
|
||||
const statusText = info.status === "pulling" ? "Baixando modelo..." :
|
||||
info.status === "verifying" ? "Verificando..." :
|
||||
info.status === "writing" ? "Salvando..." :
|
||||
info.status === "success" ? "Concluído!" :
|
||||
"Baixando...";
|
||||
setDownloadStatus(statusText);
|
||||
|
||||
// Atualizar progresso
|
||||
if (info.percent !== undefined) {
|
||||
setDownloadProgress(info.percent);
|
||||
} else {
|
||||
// Fallback: estimar progresso baseado no status
|
||||
if (info.status === "pulling") {
|
||||
setDownloadProgress(prev => prev < 10 ? 10 : prev);
|
||||
} else if (info.status === "verifying") {
|
||||
setDownloadProgress(90);
|
||||
} else if (info.status === "writing") {
|
||||
setDownloadProgress(95);
|
||||
}
|
||||
}
|
||||
|
||||
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay
|
||||
if (info.percent === 100 || info.status === "success") {
|
||||
setTimeout(() => {
|
||||
setIsInstalled(true);
|
||||
setIsDownloading(false);
|
||||
checkInstallation(selectedModelId);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then(f => f());
|
||||
};
|
||||
}, [selectedModelId]);
|
||||
|
||||
const checkInstallation = async (modelId: string) => {
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_if_model_installed', { name: modelId });
|
||||
setIsInstalled(installed);
|
||||
} catch (e) {
|
||||
console.error("Check failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
setDownloadProgress(0);
|
||||
setDownloadStatus("Iniciando download...");
|
||||
try {
|
||||
await invoke('pull_model', { name: selectedModelId });
|
||||
// O status será atualizado pelo listener quando o download completar
|
||||
// Não precisamos setar aqui pois o listener já faz isso
|
||||
} catch (error) {
|
||||
console.error("Download failed", error);
|
||||
setDownloadStatus("Falha no download. Tente novamente.");
|
||||
setDownloadProgress(0);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto mt-6 border-primary shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle>Escolha seu Modelo</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedModelId === recommendation.modelId
|
||||
? "Baseado no seu hardware, recomendamos:"
|
||||
: "Você selecionou um modelo personalizado:"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{selectedModelId === recommendation.modelId && (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800 hover:bg-green-100 dark:bg-green-900 dark:text-green-100">
|
||||
Recomendado
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Select
|
||||
value={selectedModelId}
|
||||
onValueChange={setSelectedModelId}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<SelectTrigger className="w-full h-14 text-lg">
|
||||
<SelectValue placeholder="Selecione um modelo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECOMMENDED_MODELS.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{model.size} • Min RAM: {model.minRam}GB</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="bg-muted/50 p-4 rounded-md text-sm">
|
||||
<p className="font-medium mb-1">Sobre este modelo:</p>
|
||||
<p className="text-muted-foreground">
|
||||
{selectedModelId === recommendation.modelId
|
||||
? recommendation.reason
|
||||
: "Modelo alternativo selecionado. Certifique-se de ter memória suficiente."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isDownloading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{downloadStatus || "Iniciando..."}</span>
|
||||
<span>{downloadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={downloadProgress} className="h-2" />
|
||||
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{downloadInfo.downloaded && downloadInfo.total
|
||||
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
|
||||
: downloadInfo.downloaded || ""}
|
||||
</span>
|
||||
{downloadInfo.speed && (
|
||||
<span>{downloadInfo.speed}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{isInstalled ? (
|
||||
<Button className="w-full h-12 text-lg" onClick={onComplete}>
|
||||
Ir para o Chat
|
||||
<Play className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full h-12 text-lg"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Baixando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-5 w-5" />
|
||||
Baixar e Instalar ({selectedModel.size})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
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>
|
||||
);
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { Minus, Square, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function TitleBar() {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const checkMaximized = async () => {
|
||||
try {
|
||||
const appWindow = getCurrentWindow();
|
||||
const maximized = await appWindow.isMaximized();
|
||||
if (isMounted) {
|
||||
setIsMaximized(maximized);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check window state:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkMaximized();
|
||||
|
||||
// Listener para mudanças de estado
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
const appWindow = getCurrentWindow();
|
||||
const unlisten = await appWindow.onResized(async () => {
|
||||
if (isMounted) {
|
||||
const maximized = await appWindow.isMaximized();
|
||||
setIsMaximized(maximized);
|
||||
}
|
||||
});
|
||||
|
||||
return unlisten;
|
||||
} catch (error) {
|
||||
console.error('Failed to setup resize listener:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
let unlistenFn: (() => void) | null = null;
|
||||
setupListener().then(fn => {
|
||||
unlistenFn = fn;
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (unlistenFn) {
|
||||
unlistenFn();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMinimize = async () => {
|
||||
const appWindow = getCurrentWindow();
|
||||
await appWindow.minimize();
|
||||
};
|
||||
|
||||
const handleMaximize = async () => {
|
||||
const appWindow = getCurrentWindow();
|
||||
if (isMaximized) {
|
||||
await appWindow.unmaximize();
|
||||
} else {
|
||||
await appWindow.maximize();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
const appWindow = getCurrentWindow();
|
||||
await appWindow.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-50 shrink-0"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{/* Área de arrasto com logo/título */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 h-full flex-1"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<span className="text-sm font-medium text-sidebar-foreground">
|
||||
OllaHub
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Controles de janela */}
|
||||
<div className="flex items-center h-full">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="h-full w-12 flex items-center justify-center hover:bg-sidebar-accent transition-colors"
|
||||
title="Minimizar"
|
||||
>
|
||||
<Minus className="w-4 h-4 text-sidebar-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="h-full w-12 flex items-center justify-center hover:bg-sidebar-accent transition-colors"
|
||||
title={isMaximized ? "Restaurar" : "Maximizar"}
|
||||
>
|
||||
<Square className={cn(
|
||||
"w-3.5 h-3.5 text-sidebar-foreground",
|
||||
isMaximized && "opacity-60"
|
||||
)} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="h-full w-12 flex items-center justify-center hover:bg-destructive transition-colors"
|
||||
title="Fechar"
|
||||
>
|
||||
<X className="w-4 h-4 text-sidebar-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Whitespace-only changes.
Whitespace-only changes.
Loaded 100 of 448 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user