Compare commits
35
Commits
No files matched your search
@@ -0,0 +1,186 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TAURI_VERSION: "2.9.4"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'windows-latest'
|
||||
target: 'x86_64-pc-windows-msvc'
|
||||
bundle: 'msi'
|
||||
- platform: 'macos-latest'
|
||||
target: 'aarch64-apple-darwin'
|
||||
bundle: 'dmg'
|
||||
- platform: 'macos-latest'
|
||||
target: 'x86_64-apple-darwin'
|
||||
bundle: 'dmg'
|
||||
- platform: 'ubuntu-latest'
|
||||
target: 'x86_64-unknown-linux-gnu'
|
||||
bundle: 'appimage'
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install dependencies (Ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate changelog
|
||||
run: pnpm generate-changelog
|
||||
|
||||
- name: Build Tauri app
|
||||
run: |
|
||||
cd src-tauri
|
||||
cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Build Tauri bundle
|
||||
env:
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_UPDATER_PRIVATE_KEY }}
|
||||
run: |
|
||||
cd src-tauri
|
||||
if [ -n "$TAURI_PRIVATE_KEY" ]; then
|
||||
echo "$TAURI_PRIVATE_KEY" > /tmp/private.key
|
||||
cargo tauri build --target ${{ matrix.target }} --config '{"updater":{"active":true}}'
|
||||
rm /tmp/private.key
|
||||
else
|
||||
cargo tauri build --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.platform }}-${{ matrix.target }}
|
||||
path: |
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.${{ matrix.bundle }}
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.exe
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage
|
||||
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.deb
|
||||
retention-days: 7
|
||||
|
||||
create-release:
|
||||
needs: build
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate changelog for release
|
||||
run: pnpm generate-changelog
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Extract release notes
|
||||
id: extract_notes
|
||||
run: |
|
||||
if [ -f "data/changelog.json" ]; then
|
||||
NOTES=$(node -e "
|
||||
const fs = require('fs');
|
||||
try {
|
||||
const changelog = JSON.parse(fs.readFileSync('data/changelog.json', 'utf-8'));
|
||||
const latestRelease = changelog.releases[0];
|
||||
if (latestRelease) {
|
||||
let notes = '## ' + (latestRelease.version === 'unreleased' ? 'Próxima Versão' : 'v' + latestRelease.version) + '\\n\\n';
|
||||
notes += '**Data:** ' + latestRelease.date + '\\n\\n';
|
||||
notes += '### Mudanças\\n\\n';
|
||||
latestRelease.commits.forEach(commit => {
|
||||
const icon = commit.type === 'feat' ? '✨' : '🐛';
|
||||
notes += '- ' + icon + ' **' + (commit.type === 'feat' ? 'Nova Funcionalidade' : 'Correção') + ':** ' + commit.message + '\\n';
|
||||
});
|
||||
console.log(notes);
|
||||
} else {
|
||||
console.log('## Release ${{ steps.get_version.outputs.version }}');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('## Release ${{ steps.get_version.outputs.version }}');
|
||||
}
|
||||
")
|
||||
echo "notes<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$NOTES" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "notes=## Release ${{ steps.get_version.outputs.version }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.tag }}
|
||||
name: Release ${{ steps.get_version.outputs.tag }}
|
||||
body: |
|
||||
${{ steps.extract_notes.outputs.notes }}
|
||||
|
||||
## Downloads
|
||||
|
||||
Baixe o instalador para sua plataforma nos assets abaixo.
|
||||
draft: false
|
||||
files: artifacts/**/*
|
||||
generate_release_notes: false
|
||||
|
||||
+1
-11
@@ -39,14 +39,4 @@ 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
|
||||
~/.tauri/ollahub.key
|
||||
@@ -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,36 +1,81 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
<div align="center">
|
||||
|
||||
## Getting Started
|
||||
<img src="assets/readme/hero.svg" width="100%" alt="OllaHub: desktop chat for your local Ollama models, with web search and MCP tools">
|
||||
|
||||
First, run the development server:
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[Quick start](#quick-start) · [Docs](docs/) · [Issues](https://git.evandro.dev.br/evandro/OllaHub/issues)
|
||||
|
||||
</div>
|
||||
|
||||
## What it is
|
||||
|
||||
OllaHub is a desktop chat app for the models you run with [Ollama](https://ollama.com). On first run it scans your hardware, recommends a model and downloads it. After that it chats with streaming, keeps history in a local SQLite database, and can add context from web search, MCP tools and scheduled tasks. The UI is Next.js inside a Tauri 2 window, and a Rust backend does the work.
|
||||
|
||||
OllaHub is a client, not a model runtime. It talks to the Ollama API (default `http://localhost:11434`, configurable in Settings), tries to start Ollama when it is installed but stopped, and can download the Ollama installer when it is missing.
|
||||
|
||||
## Why use it
|
||||
|
||||
- **Local by default.** Prompts go to your own Ollama, and chats live in `ollahub.db` in the app data directory, with FTS5 full-text search. Besides downloads (models, the embedding model, updates), web search is what reaches the internet.
|
||||
- **Web answers ranked on your machine.** Headless Chrome scrapes Google, Bing, Yahoo, DuckDuckGo and Startpage (order configurable), then local embeddings (`all-MiniLM-L6-v2` on ONNX Runtime) rank and prune the pages, with a BM25-like fallback when the embedding model is unavailable. A deep-research mode runs planning → searching → aggregating → validating → formulating.
|
||||
- **More than a chat box.** MCP servers run as child processes configured in `mcp_config.json`, their tools can be called from the chat, and cron tasks (search and summarize, notification, custom prompt) keep running in the background.
|
||||
|
||||
**When not to use it:** if you want a finished, tested product (version `0.1.0`, no test suite), a model runtime of its own, or a server you can run without a desktop.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Frontend** (`app/`, `components/`, `hooks/`, `lib/`, `store/`): Next.js 16 with `output: 'export'`, React 19, Tailwind 4, Radix UI and Zustand. It calls the backend through Tauri `invoke` and events.
|
||||
- **Backend** (`src-tauri/src/`): `lib.rs` holds the commands for chat (`chat_stream`), models, the Ollama lifecycle, sessions, MCP, search, tasks, export and the installer. `db.rs` handles SQLite, `web_scraper.rs` the search engines, `embeddings.rs` the ranking, and `scheduler.rs` the cron tasks stored in `tasks.json`.
|
||||
- **Models:** list, delete and pull with progress, install a local `.gguf` file, and get a recommendation for your hardware.
|
||||
- **System monitor:** CPU and RAM stats plus GPU VRAM, usage and temperature. GPU detection uses `wmic` and `nvidia-smi` on Windows and `lspci` on Linux.
|
||||
- **Updates:** the Tauri updater checks automatically. Its endpoint in `src-tauri/tauri.conf.json` points to a GitHub Releases `latest.json`.
|
||||
|
||||
More notes: [`docs/updater-setup.md`](docs/updater-setup.md) (signing keys and updater) and [`docs/style-guide.md`](docs/style-guide.md) (UI spacing, radii, accessibility).
|
||||
|
||||
## Quick start
|
||||
|
||||
No release is published on this repository yet, so build it locally. You need Node.js 20, pnpm 8, a stable Rust toolchain and the [Tauri 2 prerequisites](https://tauri.app/start/prerequisites/) (the versions come from the CI workflow). On Ubuntu the workflow also installs `libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf`. Ollama is needed at runtime.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
git clone https://git.evandro.dev.br/evandro/OllaHub.git
|
||||
cd OllaHub
|
||||
pnpm install
|
||||
|
||||
pnpm tauri dev # starts `pnpm dev` (Next.js on http://localhost:3000) inside the Tauri window
|
||||
pnpm tauri build # runs `pnpm generate-changelog && pnpm build`, then bundles the installer
|
||||
pnpm lint # ESLint
|
||||
pnpm bump-version # patch | minor | major, optional --commit
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
The workflow [`.github/workflows/release.yml`](.github/workflows/release.yml) builds `msi` (Windows), `dmg` (macOS, Apple Silicon and Intel) and `AppImage` (Linux) on pushes to `main`, `master` and `develop`, and publishes release `v<version>` on pushes to `main` or `master`.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
> [!WARNING]
|
||||
> **Two things the app does on its own.**
|
||||
> 1. Opening the chat page pulls the small helper model **`qwen2.5:0.5b`** if it is not installed yet, used for automatic chat titles. Expect an extra download on first use.
|
||||
> 2. **Closing the window only hides it.** The close request is intercepted (`prevent_close`) so scheduled tasks keep running, but there is no tray icon to bring the window back yet; the tray step is still pending in [`IMPLEMENTATION_GUIDE.md`](IMPLEMENTATION_GUIDE.md).
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## Project status
|
||||
|
||||
## Learn More
|
||||
Early: version `0.1.0`, 47 commits between 2025-10-27 and 2025-11-27, no automated tests (`package.json` defines `dev`, `build`, `start`, `lint`, `generate-changelog` and `bump-version`). Known limitations:
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
- **No tray icon yet:** closing the window hides it (see the warning above).
|
||||
- **"Start with the system" does nothing yet:** the setting only toggles a stored flag; the code has a `TODO` until `tauri-plugin-autostart` is configured.
|
||||
- **RAG inside `chat_stream` is not wired:** the `enable_rag` branch is commented out as `TODO`; the frontend assembles web context before the request.
|
||||
- **Embeddings outside Windows:** the ONNX Runtime library is downloaded automatically only on Windows. On macOS and Linux, provide it in the app data folder or on the system path, otherwise ranking falls back to BM25-like scoring.
|
||||
- **Unused leftovers:** `src-tauri/scripts/scraper.py` is not called by the Rust backend, and the 188 model files in `data/models/` are not referenced by the app code.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## License
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
No published license: all rights reserved.
|
||||
|
||||
## Deploy on Vercel
|
||||
Built with [Tauri](https://tauri.app), [Next.js](https://nextjs.org), [Radix UI](https://www.radix-ui.com), [Tailwind CSS](https://tailwindcss.com), [Zustand](https://zustand-demo.pmnd.rs), [Lucide](https://lucide.dev), [headless_chrome](https://github.com/rust-headless-chrome/rust-headless-chrome), [ONNX Runtime](https://onnxruntime.ai) and the [all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) model.
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
<div align="center">
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
<sub>Made by <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
|
||||
|
||||
</div>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1544
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; }
|
||||
+27
-8
@@ -1,13 +1,22 @@
|
||||
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";
|
||||
import { AppUpdaterProvider } from "@/components/app-updater-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 +25,19 @@ 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}
|
||||
<AppUpdaterProvider />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
|
||||
<title id="title">OllaHub</title>
|
||||
<desc id="desc">OllaHub: a desktop window with a chat sidebar, a local Ollama model download in progress, an answer backed by numbered web sources, and Web and MCP toggles in the message box.</desc>
|
||||
|
||||
<rect width="1200" height="420" rx="24" fill="#09090B"/>
|
||||
|
||||
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
|
||||
<text x="64" y="170" font-size="64" font-weight="700" fill="#FAFAFA">OllaHub</text>
|
||||
<text x="64" y="226" font-size="28" fill="#FAFAFA">Desktop chat for your local Ollama</text>
|
||||
<text x="64" y="264" font-size="28" fill="#FAFAFA">models, with web search and MCP.</text>
|
||||
<text x="64" y="322" font-size="20" fill="#9F9FA9" font-family="ui-monospace, 'SF Mono', Consolas, monospace">Tauri 2 · Rust · Next.js 16 · React 19</text>
|
||||
</g>
|
||||
|
||||
<g id="window" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
|
||||
<rect x="608" y="52" width="528" height="316" rx="14" fill="#09090B" stroke="#27272A" stroke-width="2"/>
|
||||
<path d="M622 52 h500 a14 14 0 0 1 14 14 v26 h-528 v-26 a14 14 0 0 1 14 -14 z" fill="#18181B"/>
|
||||
<text x="628" y="79" font-size="18" fill="#9F9FA9">OllaHub</text>
|
||||
<g fill="none" stroke="#9F9FA9" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M1052 72 h14"/>
|
||||
<rect x="1078" y="65" width="14" height="14" rx="2"/>
|
||||
<path d="M1106 65 l14 14 M1120 65 l-14 14"/>
|
||||
</g>
|
||||
<path d="M608 92 h528" stroke="#27272A" stroke-width="2"/>
|
||||
|
||||
<path d="M608 92 h144 v276 h-130 a14 14 0 0 1 -14 -14 z" fill="#18181B"/>
|
||||
<rect x="622" y="108" width="116" height="34" rx="8" fill="#1447E6"/>
|
||||
<text x="680" y="131" font-size="18" fill="#FAFAFA" text-anchor="middle">New chat</text>
|
||||
<rect x="622" y="166" width="100" height="10" rx="5" fill="#27272A"/>
|
||||
<rect x="622" y="192" width="84" height="10" rx="5" fill="#27272A"/>
|
||||
<rect x="622" y="218" width="94" height="10" rx="5" fill="#27272A"/>
|
||||
<rect x="622" y="244" width="72" height="10" rx="5" fill="#27272A"/>
|
||||
|
||||
<text x="772" y="128" font-size="18" fill="#FAFAFA" font-family="ui-monospace, 'SF Mono', Consolas, monospace">qwen2.5:0.5b</text>
|
||||
<rect x="772" y="140" width="344" height="10" rx="5" fill="#27272A"/>
|
||||
<rect x="772" y="140" width="220" height="10" rx="5" fill="#1447E6"/>
|
||||
|
||||
<rect x="772" y="170" width="344" height="116" rx="12" fill="#201E25"/>
|
||||
<rect x="790" y="190" width="290" height="10" rx="5" fill="#9F9FA9"/>
|
||||
<rect x="790" y="212" width="300" height="10" rx="5" fill="#9F9FA9"/>
|
||||
<rect x="790" y="234" width="180" height="10" rx="5" fill="#9F9FA9"/>
|
||||
<text x="790" y="272" font-size="18" fill="#9F9FA9">Sources</text>
|
||||
<g font-size="18" fill="#FAFAFA" text-anchor="middle">
|
||||
<rect x="866" y="254" width="26" height="26" rx="13" fill="#27272A"/>
|
||||
<text x="879" y="273">1</text>
|
||||
<rect x="898" y="254" width="26" height="26" rx="13" fill="#27272A"/>
|
||||
<text x="911" y="273">2</text>
|
||||
<rect x="930" y="254" width="26" height="26" rx="13" fill="#27272A"/>
|
||||
<text x="943" y="273">3</text>
|
||||
</g>
|
||||
|
||||
<rect x="772" y="302" width="344" height="40" rx="20" fill="#18181B" stroke="#27272A" stroke-width="2"/>
|
||||
<rect x="782" y="309" width="60" height="26" rx="13" fill="#27272A"/>
|
||||
<text x="812" y="328" font-size="18" fill="#FAFAFA" text-anchor="middle">Web</text>
|
||||
<rect x="850" y="309" width="62" height="26" rx="13" fill="#27272A"/>
|
||||
<text x="881" y="328" font-size="18" fill="#FAFAFA" text-anchor="middle">MCP</text>
|
||||
<circle cx="1096" cy="322" r="13" fill="#1447E6"/>
|
||||
<path d="M1096 328 v-12 M1090 321 l6 -6 l6 6" fill="none" stroke="#FAFAFA" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
-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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UpdateDialog } from '@/components/modals/update-dialog';
|
||||
import { useAppUpdater } from '@/hooks/use-app-updater';
|
||||
import { useSettingsStore } from '@/store/settings-store';
|
||||
|
||||
export function AppUpdaterProvider() {
|
||||
const { updateAvailable, checkUpdate } = useAppUpdater();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasChecked, setHasChecked] = useState(false);
|
||||
const autoCheckEnabled = useSettingsStore((state) => state.autoCheckUpdates ?? true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoCheckEnabled || hasChecked) return;
|
||||
|
||||
// Verificar após um delay para não interferir no carregamento inicial
|
||||
const timer = setTimeout(() => {
|
||||
checkUpdate().then(() => {
|
||||
setHasChecked(true);
|
||||
});
|
||||
}, 5000); // 5 segundos após o app carregar
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoCheckEnabled, hasChecked, checkUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateAvailable && !isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [updateAvailable, isOpen]);
|
||||
|
||||
return (
|
||||
<UpdateDialog
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,235 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import React, { 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;
|
||||
highlightTerm?: string;
|
||||
highlightIndex?: number;
|
||||
messageIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente de mensagem do chat otimizado com React.memo
|
||||
*
|
||||
* Durante streaming, usa renderização simplificada (texto puro) para evitar
|
||||
* recálculo custoso da árvore DOM e syntax highlight a cada token.
|
||||
* Markdown completo é aplicado apenas quando a mensagem está finalizada.
|
||||
*/
|
||||
function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: 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]);
|
||||
|
||||
// Processar highlight separadamente (após limpeza de conteúdo)
|
||||
// Desabilitar durante streaming para performance
|
||||
const highlightedContent = useMemo(() => {
|
||||
// Não processar highlight durante streaming
|
||||
if (isStreaming) return null;
|
||||
|
||||
if (!highlightTerm || highlightTerm.trim().length < 2 || (message.role !== 'user' && message.role !== 'assistant')) {
|
||||
return null; // Não há highlight
|
||||
}
|
||||
|
||||
const term = highlightTerm.trim();
|
||||
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
||||
const content = processedContent;
|
||||
|
||||
// Encontrar todos os matches
|
||||
const matches: Array<{ start: number; end: number; text: string }> = [];
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
matches.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
text: match[0]
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
// Construir conteúdo com highlights
|
||||
let highlighted = '';
|
||||
let lastIndex = 0;
|
||||
|
||||
matches.forEach((match, idx) => {
|
||||
// Adicionar texto antes do match
|
||||
highlighted += content.substring(lastIndex, match.start);
|
||||
|
||||
// Adicionar match com highlight (laranja para atual, amarelo para outros)
|
||||
const isCurrent = highlightIndex !== undefined && idx === highlightIndex;
|
||||
const className = isCurrent
|
||||
? 'bg-orange-400/60 dark:bg-orange-500/60 px-0.5 rounded'
|
||||
: 'bg-yellow-200/50 dark:bg-yellow-800/50 px-0.5 rounded';
|
||||
|
||||
highlighted += `<mark class="${className}">${match.text}</mark>`;
|
||||
lastIndex = match.end;
|
||||
});
|
||||
|
||||
// Adicionar texto restante
|
||||
highlighted += content.substring(lastIndex);
|
||||
return highlighted;
|
||||
}, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]);
|
||||
|
||||
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" data-message-index={messageIndex}>
|
||||
{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"
|
||||
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
|
||||
>
|
||||
{!highlightedContent && 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>
|
||||
) : isStreaming ? (
|
||||
// Renderização LEVE durante streaming: texto simples com formatação básica
|
||||
// Evita recálculo de ReactMarkdown e syntax highlight a cada token
|
||||
<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",
|
||||
"relative"
|
||||
)}>
|
||||
<div className="whitespace-pre-wrap font-sans">
|
||||
{processedContent}
|
||||
</div>
|
||||
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
||||
</div>
|
||||
) : (
|
||||
// Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
|
||||
<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",
|
||||
"prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded"
|
||||
)}>
|
||||
{highlightedContent ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownRenderers}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatMessage com React.memo para evitar re-renders desnecessários
|
||||
*
|
||||
* Comparação customizada:
|
||||
* - Compara conteúdo da mensagem
|
||||
* - Compara estado de streaming
|
||||
* - Compara termo de highlight e índice
|
||||
*/
|
||||
export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProps) => {
|
||||
// Se streaming mudou, sempre re-renderizar
|
||||
if (prevProps.isStreaming !== nextProps.isStreaming) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Durante streaming, só re-renderizar se conteúdo mudou
|
||||
if (nextProps.isStreaming) {
|
||||
return prevProps.message.content === nextProps.message.content;
|
||||
}
|
||||
|
||||
// Quando não está em streaming, comparar todas as props relevantes
|
||||
return (
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.highlightTerm === nextProps.highlightTerm &&
|
||||
prevProps.highlightIndex === nextProps.highlightIndex &&
|
||||
prevProps.messageIndex === nextProps.messageIndex
|
||||
);
|
||||
});
|
||||
|
||||
// Adicionar displayName para debugging
|
||||
ChatMessage.displayName = 'ChatMessage';
|
||||
@@ -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,290 @@
|
||||
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, ChevronUp, ChevronDown } 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;
|
||||
searchQuery?: string;
|
||||
onNavigateMatch?: (sessionId: string, direction: 'prev' | 'next') => void;
|
||||
}
|
||||
|
||||
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,
|
||||
searchQuery = '',
|
||||
onNavigateMatch
|
||||
}: SidebarListProps) {
|
||||
const [localSearchInput, setLocalSearchInput] = 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 (!localSearchInput.trim()) {
|
||||
if (onSearch) {
|
||||
onSearch('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce: aguardar antes de buscar
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (onSearch && localSearchInput.trim().length >= 2) {
|
||||
onSearch(localSearchInput.trim());
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [localSearchInput, onSearch]);
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setLocalSearchInput('');
|
||||
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={localSearchInput}
|
||||
onChange={(e) => setLocalSearchInput(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" />
|
||||
)}
|
||||
{localSearchInput && !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 flex-1 min-w-0">
|
||||
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
|
||||
{session.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
|
||||
{formatDate(session.updated_at)}
|
||||
</p>
|
||||
{searchQuery && session.match_count !== undefined && session.match_count > 0 && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
{session.match_count} {session.match_count === 1 ? 'ocorrência' : 'ocorrências'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navegação de matches - aparece apenas quando selecionado e há busca ativa */}
|
||||
{isSelected && searchQuery && session.match_count !== undefined && session.match_count > 0 && onNavigateMatch && (
|
||||
<div className="flex flex-col gap-0.5 mr-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onNavigateMatch(session.id, 'prev');
|
||||
}}
|
||||
aria-label="Ocorrência anterior"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onNavigateMatch(session.id, 'next');
|
||||
}}
|
||||
aria-label="Próxima ocorrência"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Download, Loader2, X, Sparkles } from 'lucide-react';
|
||||
import { useAppUpdater } from '@/hooks/use-app-updater';
|
||||
import { useSettingsStore } from '@/store/settings-store';
|
||||
|
||||
interface UpdateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function UpdateDialog({ open, onOpenChange }: UpdateDialogProps) {
|
||||
const {
|
||||
currentVersion,
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
isChecking,
|
||||
isDownloading,
|
||||
downloadProgress,
|
||||
error,
|
||||
installUpdate,
|
||||
} = useAppUpdater();
|
||||
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
const setAutoCheckUpdates = useSettingsStore((state) => state.setAutoCheckUpdates);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (dontShowAgain) {
|
||||
setAutoCheckUpdates(false);
|
||||
}
|
||||
await installUpdate();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontShowAgain) {
|
||||
setAutoCheckUpdates(false);
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!updateAvailable && !isChecking) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open && updateAvailable} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
Atualização Disponível
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Uma nova versão do OllaHub está disponível para download.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Versão Atual</span>
|
||||
<span className="font-mono font-medium">{currentVersion}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Nova Versão</span>
|
||||
<span className="font-mono font-medium text-primary">
|
||||
{updateInfo?.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateInfo?.body && (
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{updateInfo.body}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDownloading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Baixando atualização...</span>
|
||||
<span className="font-medium">{downloadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={downloadProgress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="dont-show-updates"
|
||||
checked={dontShowAgain}
|
||||
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="dont-show-updates"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Não verificar atualizações automaticamente
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Depois
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInstall}
|
||||
disabled={isDownloading || isChecking}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Baixando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Atualizar Agora
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Loaded 100 of 459 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user