Author SHA1 Message Date
evandro f0b7db8c12 docs(readme): redesenha o README com hero em SVG [skip ci] 2026-10-24 04:57:52 -03:00
evandro 2835957e40 docs: redesign do README (padrão TwitterBR-MOD) — dados atualizados
Build and Release / build (appimage, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 6m25s
Build and Release / build (dmg, macos-latest, aarch64-apple-darwin) (push) Canceled after 0s
Build and Release / build (dmg, macos-latest, x86_64-apple-darwin) (push) Canceled after 0s
Build and Release / build (msi, windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Build and Release / create-release (push) Canceled after 0s
2026-10-13 21:50:58 -03:00
Evandro Fonseca Junior 3e57b78aa7 Merge pull request #7 from evandrodevbr:develop
Build and Release / build (appimage, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 6m18s
Build and Release / build (dmg, macos-latest, aarch64-apple-darwin) (push) Canceled after 0s
Build and Release / build (dmg, macos-latest, x86_64-apple-darwin) (push) Canceled after 0s
Build and Release / build (msi, windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Build and Release / create-release (push) Canceled after 0s
chore: add 'develop' branch to release workflow triggers
2025-11-27 23:04:26 -03:00
Evandro Fonseca Junior cde8e5ac00 chore: add 'develop' branch to release workflow triggers
- Included 'develop' branch in the GitHub Actions release workflow to enable automated releases from the development branch, enhancing CI/CD processes.
2025-11-27 23:03:58 -03:00
Evandro Fonseca Junior 6fdbd4bfe6 chore: update pnpm workspace configuration
- Added root package to the pnpm workspace configuration to streamline dependency management across the project.
2025-11-27 23:02:59 -03:00
Evandro Fonseca Junior d01d26e74f Merge pull request #6 from evandrodevbr:develop
chore: streamline pnpm installation in release workflow
2025-11-27 22:59:06 -03:00
Evandro Fonseca Junior e51a54c568 Merge remote-tracking branch 'origin/master' into develop 2025-11-27 22:58:59 -03:00
Evandro Fonseca Junior a319a9cf8d chore: streamline pnpm installation in release workflow
- Removed duplicate pnpm installation steps in the GitHub Actions release workflow.
- Ensured consistent setup of pnpm across jobs to optimize workflow efficiency.
2025-11-27 22:58:29 -03:00
Evandro Fonseca Junior 1daa6c77ee Merge pull request #5 from evandrodevbr:develop
remove: delete the document "Estabilizar Scraper Rust (Browser Resurrection, Send e Retry)" as it is no longer needed for the project. This document contained analysis, context, and implementation plans for improving the scraper's resilience and functionality.
2025-11-27 22:54:28 -03:00
Evandro Fonseca Junior 3e5727ffc7 remove: delete the document "Estabilizar Scraper Rust (Browser Resurrection, Send e Retry)" as it is no longer needed for the project. This document contained analysis, context, and implementation plans for improving the scraper's resilience and functionality. 2025-11-27 22:53:55 -03:00
Evandro Fonseca Junior 162a6f37f7 Merge pull request #4 from evandrodevbr:app-automatic-att
feat: integrate automatic update checks and enhance settings UI
2025-11-27 22:48:06 -03:00
Evandro Fonseca Junior 6c366eecb0 feat: integrate automatic update checks and enhance settings UI
- Added functionality for automatic update checks in the settings page, allowing users to enable or disable this feature.
- Integrated app version display and update availability notifications.
- Updated settings store to manage auto-check updates state and handle version migration.
- Enhanced layout with a new card for update settings, improving user experience and accessibility.
2025-11-27 22:47:48 -03:00
Evandro Fonseca Junior 6cdc8d4294 Merge pull request #3 from evandrodevbr:optimizations
Optimizations
2025-11-27 21:53:56 -03:00
Evandro Fonseca Junior 0d19f496bb feat(chat): implement infinite scroll for message loading and optimize chat message rendering
- Added infinite scroll functionality to load older messages when the user scrolls near the top of the chat.
- Implemented pagination state management to track loading status and available messages.
- Enhanced ChatMessage component with React.memo for performance optimization during streaming.
- Updated database interactions to support paginated message retrieval, improving efficiency in loading chat history.
2025-11-27 21:53:27 -03:00
Evandro Fonseca Junior e53e49c8f8 feat(chat): implement speculative web search and embeddings commands
- Added speculative search functionality to enhance query processing by detecting when a web search may be needed based on user input patterns.
- Integrated commands for managing embeddings, including downloading models, checking availability, calculating relevance scores, generating embeddings, and pruning context.
- Optimized chat streaming by buffering tokens to reduce overhead and improve responsiveness.
- Implemented lazy initialization for the headless browser to conserve memory until needed.
- Enhanced web scraping capabilities with a static scraping method for faster content retrieval.
2025-11-27 21:19:16 -03:00
Evandro Fonseca Junior ba03131a78 feat(chat): enhance search functionality and message highlighting
- Added search match navigation to allow users to cycle through search results in chat sessions.
- Implemented highlighting of search terms in chat messages for better visibility.
- Updated sidebar to display match counts for sessions based on search results.
- Enhanced session search logic in the database to include match counts for improved user experience.
- Refactored chat message component to support dynamic highlighting of search terms.
2025-11-27 20:39:39 -03:00
Evandro Fonseca Junior 1a64f5aa18 Merge pull request #2 from evandrodevbr/full-rust-backend
Full-rust-backend
2025-11-27 18:46:03 -03:00
Evandro Fonseca Junior 2b09462538 feat: integrate TitleBar component across chat, settings, and tasks pages
- Added a new TitleBar component to enhance the UI consistency across chat, settings, and tasks pages.
- Updated layout structure to support responsive design and improve user experience.
- Adjusted container styles to ensure proper overflow handling and visual appeal.
- Enhanced Tauri configuration for improved window dimensions and capabilities.
2025-11-27 18:30:34 -03:00
Evandro Fonseca Junior ad2ce1e887 feat: enhance chat and settings UI with responsive design and new components
- Updated global styles to improve layout and prevent overflow in chat and settings pages.
- Introduced new sidebar chat item styles for better truncation and overflow handling.
- Enhanced settings page layout for improved responsiveness and user experience.
- Added icons to category selection in chat input for better visual representation.
- Refactored task management UI for improved accessibility and usability.
2025-11-27 18:16:20 -03:00
Evandro Fonseca Junior 6a7eeaf7af feat(chat): implement search functionality for chat sessions
- Added search capability to the chat interface, allowing users to find sessions by title or message content.
- Integrated debounce logic for search input to enhance performance and user experience.
- Updated the sidebar to include a search input field and loading indicator during search operations.
- Implemented full-text search in the database for efficient session retrieval based on user queries.
- Enhanced session management by filtering out orphan sessions during search results.
2025-11-27 17:58:33 -03:00
Evandro Fonseca Junior 8c546f6474 feat(chat): implement adaptive timeout and enhanced search functionality
- Introduced adaptive timeout logic for web searches and chat streaming, improving responsiveness and efficiency.
- Updated `smartSearchRag` method to support timeout adjustments based on search rounds.
- Enhanced `executeProgressiveSearch` to utilize adaptive timeouts and improve search result handling.
- Added new utility functions for calculating adaptive timeouts based on rounds and attempts.
- Integrated event listeners for chat session management, improving user experience during chat interactions.
- Refactored chat message handling to accommodate new streaming and timeout features.
2025-11-27 17:32:46 -03:00
Evandro Fonseca Junior f2e8a1b933 feat: enhance chat UI and functionality with new scrollbar and animation features
- Added custom scrollbar styles for sources display to improve aesthetics and usability.
- Introduced animations for reasoning steps to enhance user experience during chat interactions.
- Refactored chat page to group thinking messages, improving message handling and display.
- Updated sources display component for better favicon handling and user interaction.
2025-11-27 16:57:50 -03:00
Evandro Fonseca Junior e5f062040c feat: enhance UI and functionality across chat and settings
- Added custom scrollbar styles for improved aesthetics and usability across the interface.
- Implemented responsive adjustments for chat sidebar and input components to enhance mobile experience.
- Integrated tooltip components in select elements for better user guidance and information display.
- Updated chat input and message components for improved styling and functionality.
- Enhanced settings page with semantic query expansion options and improved model selection UI.
- Refactored sidebar list to include better date formatting and tooltip support for session titles.
- Introduced GPU stats monitoring in the system panel for better hardware insights.
2025-11-26 19:04:46 -03:00
Evandro Fonseca Junior 84ea2dec19 feat: add emoji-regex and remove-accents dependencies; enhance chat and setup pages
- Added `emoji-regex` and `remove-accents` packages to improve text processing capabilities.
- Implemented automatic Ollama startup in the chat page for better user experience.
- Enhanced setup page to include GPU selection based on hardware specifications.
- Updated chat input to support real-time validation and improved user feedback.
- Refactored message handling in chat to incorporate new processing features.
2025-11-26 16:12:16 -03:00
Evandro Fonseca Junior 4275295e4f ultima antes do format 2025-11-22 01:59:12 -03:00
Evandro Fonseca Junior 4006a72eec feat(ui): enhance chat UI with new components and styling
refactor(chat): improve message display and actions
feat(chat): add high contrast mode toggle
style(chat): update spacing and sizing for better readability
feat(chat): add new Section and Stack layout components
docs: add UI style guide documentation
fix(chat): improve browser connection handling in scraper
feat(settings): expand default sources and improve UI
refactor: consolidate chat page layout and components
2025-11-20 15:13:54 -03:00
Evandro Fonseca Junior c06e7ae5cf feat(web-search): implement two-phase smart search pipeline
Add metadata-first search approach followed by targeted scraping of top URLs. Includes:
- New `smartSearchRag` method in web search service
- Metadata-only search endpoint in Tauri backend
- Bulk URL scraping capability
- Payload content override in chat messages
2025-11-20 05:59:09 -03:00
Evandro Fonseca Junior 5c1a859925 feat: enhance chat functionality with web context debugging and improved message handling
- Added state management for web context and sources to improve debugging capabilities in chat.
- Implemented a debug view for displaying extracted context and sources used in chat responses.
- Refactored message handling to incorporate web context into user messages, enhancing response accuracy.
- Updated UI components to support new debugging features and improve user interaction.
2025-11-20 05:34:44 -03:00
Evandro Fonseca Junior 3d91678109 fix: update maxResults in webSearch settings to improve search functionality
- Increased maxResults from 5 to 10 in the webSearch configuration to enhance user experience by allowing more search results to be displayed.
2025-11-20 05:30:24 -03:00
Evandro Fonseca Junior a44d1bba44 feat: enhance chat and settings functionality with debugging and new features
- Integrated useRouter for navigation in chat and settings pages, improving user experience.
- Added detailed debug logging for web context generation and chat payloads to assist in troubleshooting.
- Updated settings page to include a hardware dashboard and enhanced sources configuration management.
- Implemented auto-start feature for the application and improved task management with new scheduled task options.
- Refined user interface components for better accessibility and usability across chat and settings pages.
2025-11-20 05:25:34 -03:00
Evandro Fonseca Junior 8834ef2b4f feat: update dependencies and enhance chat functionality
- Added new Radix UI components including checkbox, label, popover, slider, switch, and tooltip to improve UI interactions.
- Integrated react-syntax-highlighter for better code rendering in chat messages.
- Updated package.json and pnpm-lock.yaml to include new dependencies and ensure compatibility.
- Enhanced chat input to support web search toggling, improving user experience during message composition.
- Implemented auto-saving of chat sessions and improved session management features.
- Refactored chat message rendering to clean up metadata and improve display quality.
2025-11-20 04:26:35 -03:00
Evandro Fonseca Junior 7d169d8a61 Merge pull request #1 from evandrodevbr/develop
feat: enhance chat functionality with markdown support and default prompts
2025-11-19 00:40:26 -03:00
Evandro Fonseca Junior 032722e21b feat: enhance chat functionality with markdown support and default prompts
- Added support for rendering markdown in chat messages using ReactMarkdown with custom renderers.
- Integrated a default format prompt for the chat system, improving user experience by initializing with a predefined prompt.
- Updated the prompt generator to handle model selection more robustly, including loading states and fallback options for model selection.
- Enhanced UI components for better accessibility and user feedback during prompt generation.
2025-11-19 00:34:57 -03:00
Evandro Fonseca Junior b2a06ed994 fix: address issues in model pull and chat API
- Refactored model pull logic to improve error handling and user feedback.
- Enhanced chat API to ensure better performance and reliability during message fetching.
- Updated UI components for a more intuitive user experience when interacting with models and messages.
2025-11-19 00:01:14 -03:00
Evandro Fonseca Junior ad9d5d32d2 chore: remove deprecated files and configurations
- Deleted .gitignore, biome.json, next.config.ts, package.json, pnpm-lock.yaml, pnpm-workspace.yaml, postcss.config.mjs, README.md, and various API, component, and utility files to clean up the codebase.
- Removed unused database files and migration scripts to streamline the project structure.
- Eliminated obsolete hooks and services to enhance maintainability and reduce clutter.
2025-11-18 21:43:11 -03:00
459 changed files with 59857 additions and 15539 deletions

No files matched your search

+186
View File
@@ -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
View File
@@ -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
+309
View File
@@ -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
+66 -21
View File
@@ -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:
![Version 0.1.0](https://img.shields.io/badge/version-0.1.0-555?style=flat-square)
![Tauri 2](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square&logo=tauri&logoColor=white)
![Next.js 16](https://img.shields.io/badge/Next.js-16.0.3-000000?style=flat-square&logo=nextdotjs&logoColor=white)
![React 19](https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=black)
![Platforms](https://img.shields.io/badge/platforms-Windows_%7C_macOS_%7C_Linux-1447e6?style=flat-square)
[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 }
);
}
}
-58
View File
@@ -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 }
);
}
}
-54
View File
@@ -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 }
);
}
}
-58
View File
@@ -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 }
);
}
}
-89
View File
@@ -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 }
);
}
}
-359
View File
@@ -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 || "",
]);
}
}
}
-58
View File
@@ -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 }
);
}
}
-226
View File
@@ -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 || "",
]);
}
}
}
-46
View File
@@ -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 }
);
}
}
-53
View File
@@ -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 }
);
}
}
-128
View File
@@ -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 }
);
}
}
-62
View File
@@ -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 }
);
}
}
-52
View File
@@ -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 }
);
}
}
View File
Whitespace-only changes.
-377
View File
@@ -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 });
}
}
-28
View File
@@ -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 }
);
}
}
-18
View File
@@ -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 });
}
}
-59
View File
@@ -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);
}
}
-114
View File
@@ -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 });
}
}
-32
View File
@@ -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 }
);
}
}
-74
View File
@@ -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 },
);
}
}
-29
View File
@@ -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 });
}
}
-42
View File
@@ -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 }
);
}
}
-25
View File
@@ -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 }
);
}
}
-108
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+368 -127
View File
@@ -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
View File
@@ -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
View File
@@ -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
+48
View File
@@ -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>
);
}
+551
View File
@@ -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>
);
}
+59
View File
@@ -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
View File
@@ -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"
}
}
}
}
+22
View File
@@ -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": {}
}
-75
View File
@@ -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>
);
}
+40
View File
@@ -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}
/>
);
}
-808
View File
@@ -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>
);
}
-165
View File
@@ -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>
);
}
-176
View File
@@ -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>
);
}
-293
View File
@@ -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>
);
}
-164
View File
@@ -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>
)}
</>
);
}
-111
View File
@@ -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>
);
}
-50
View File
@@ -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>
);
}
+261
View File
@@ -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>
);
}
+235
View File
@@ -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';
+47
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+140
View File
@@ -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}
/>
),
};
+419
View File
@@ -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>
);
}
+184
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+529
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+192
View File
@@ -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>
);
}
+188
View File
@@ -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>
);
}
+134
View File
@@ -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>
);
}
+149
View File
@@ -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>
);
}
+290
View File
@@ -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>
);
}
+154
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+173
View File
@@ -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>
);
}
+117
View File
@@ -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>
);
}
+164
View File
@@ -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>
</>
);
}
+26
View File
@@ -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>
);
}
+85
View File
@@ -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>
)
}
View File
Whitespace-only changes.
View File
Whitespace-only changes.
-297
View File
@@ -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
);
}
);
-356
View File
@@ -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>
);
}
-272
View File
@@ -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>
);
}
-309
View File
@@ -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>
);
}
-101
View File
@@ -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>
);
}
-111
View File
@@ -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>
);
}
+315
View File
@@ -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>
)
}
+153
View File
@@ -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>
);
}
-27
View File
@@ -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>
);
}
-61
View File
@@ -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>
);
}
-475
View File
@@ -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>
);
}
-21
View File
@@ -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>
);
}
-298
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+271
View File
@@ -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>
);
}
+200
View File
@@ -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>
);
}
-336
View File
@@ -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>
);
}
+73
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+222
View File
@@ -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