Author SHA1 Message Date
Evandro Fonseca Junior a7cbd1a3b4 chore: expurgo de blobs excedentes e realinhamento de estado
release.yml (push) Invalid workflow file
2026-02-22 14:06:01 -03:00
Evandro Fonseca Junior 593efd42e0 Update footer text in README.md 2026-02-22 07:36:16 -03:00
Evandro Fonseca Junior e6d41478ac Revise README for better clarity and structure
Updated README to enhance clarity and organization, including a new structure for features and installation instructions.
2026-02-22 07:35:40 -03:00
Evandro Fonseca Junior bd1e3ca2e6 refactor: optimize system health monitoring and UI interactions
- Removed unnecessary delay in chat redirection for immediate user feedback.
- Enhanced TitleBar component with a system health indicator, displaying RAM and CPU usage.
- Implemented optimistic UI updates for model downloads and Ollama installation, improving responsiveness.
- Introduced background health monitoring to prevent resource overload during operations.
- Added cleanup logic for child processes to ensure proper resource management during application shutdown.
2025-11-28 15:42:40 -03:00
Evandro Fonseca Junior c7da6cb8a8 feat: enhance OllaHub with new features and UI improvements
- Introduced a comprehensive README.md detailing the application, its architecture, and key features.
- Implemented a local-first architecture with deep research capabilities, including query decomposition and parallel web scraping.
- Added a reactive UI built with React and Next.js, featuring Tailwind CSS for styling and Zustand for state management.
- Integrated a task scheduler for recurring tasks and system monitoring for real-time resource management.
- Enhanced settings page with debug mode options and improved hardware dashboard loading states.
- Updated chat interface to include debug data visualization and improved user experience during message streaming.
- Removed unused favicon and updated application icons for better branding.
- Improved navigation handling with non-blocking transitions and state management for setup completion.
2025-11-28 15:15:38 -03:00
Evandro Fonseca Junior 55749c07e3 chore: refine Next.js configuration and improve error handling
- Simplified the Next.js webpack configuration to apply raw-loader for markdown files universally.
- Updated build script in package.json to ensure webpack is used during the build process.
- Enhanced error logging across various hooks to provide clearer messages when exceptions occur, improving debugging capabilities.
2025-11-28 00:38:58 -03:00
Evandro Fonseca Junior 655d9c1b45 chore: update Next.js configuration and release workflow
- Modified Next.js configuration to conditionally apply raw-loader for markdown files only on the client side.
- Increased static page generation timeout to 300 seconds for larger builds.
- Enhanced GitHub Actions release workflow by adding a step to build the Next.js frontend, ensuring the 'out' directory exists post-build.
- Simplified the beforeBuildCommand in Tauri configuration to only include the build step.
2025-11-28 00:20:03 -03:00
Evandro Fonseca Junior 5fdb9861ef chore: update application identifier in tauri configuration
- Changed the application identifier from "com.tauri.dev" to "dev.evandrodevbr.ollahub" in the tauri configuration file to reflect the new project namespace.
2025-11-27 23:55:30 -03:00
Evandro Fonseca Junior 6a1143282e chore: update release workflow and system monitor
- Added Rust caching step to the GitHub Actions release workflow to improve build performance.
- Installed Tauri CLI version 2.9 in the release workflow for better compatibility.
- Modified VRAM string handling in system_monitor.rs to ensure proper string conversion.
- Updated memory usage variable naming for clarity in GPU stats function.
2025-11-27 23:29:00 -03:00
Evandro Fonseca Junior ea021e7ca9 chore: upgrade pnpm version in release workflow
- Updated pnpm installation from version 8 to version 9 in the GitHub Actions release workflow to leverage new features and improvements.
2025-11-27 23:16:38 -03:00
Evandro Fonseca Junior fce831acd3 chore: specify shell for commands in release workflow
- Added explicit 'bash' shell specification for multiple steps in the GitHub Actions release workflow to ensure consistent execution environment across jobs.
2025-11-27 23:12:59 -03:00
Evandro Fonseca Junior 2405ecb6de chore: enhance pnpm installation logic in release workflow
- Updated the GitHub Actions release workflow to conditionally install frontend dependencies based on the presence of a pnpm-lock.yaml file, ensuring more flexible dependency management.
2025-11-27 23:09:32 -03:00
Evandro Fonseca Junior 3a9bdcac2c 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 82fca5b615 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 8947b2c7bb Merge remote-tracking branch 'origin/master' into develop 2025-11-27 22:58:59 -03:00
Evandro Fonseca Junior a1f8335d3f 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 0488a4205a 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 350e7be790 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 9d79cb2d0b 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 ac63066a2a 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 f2b4303628 Merge pull request #3 from evandrodevbr:optimizations
Optimizations
2025-11-27 21:53:56 -03:00
Evandro Fonseca Junior 337f7b3056 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 5fb1489eff 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 6cc3f71f38 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 0358a6ea2d 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 73d70101f4 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
Evandro Fonseca Junior a8bb7cc22d feat(ollama): enhance model pull with improved error handling, persistence, and UI
- Add catalog metadata lookup for better error messages with hints (e.g., for missing manifests or tags)
- Persist downloaded model references in saved remote models for future reuse
- Replace searchRemoteModels import with getSavedRemoteModels in search route
- Enhance pull progress UI to display download bytes, digest, and fallback message when data is unavailable
- Improve error payload with model details, URL, and tags count to guide users toward valid tags or models
2025-10-29 19:42:24 -03:00
514 changed files with 77892 additions and 14482 deletions

No files matched your search

@@ -0,0 +1,569 @@
---
name: Integração llama.cpp nativo e download Hugging Face
overview: Implementar download direto de modelos GGUF do Hugging Face, integração com llama.cpp nativo via Rust, listagem de modelos locais e UI experimental para alternar entre Ollama e llama.cpp nativo.
todos:
- id: hf-download-module
content: Criar módulo huggingface_download.rs com download_gguf_model() e streaming de progresso
status: pending
- id: hf-list-models
content: Implementar list_huggingface_gguf_models() com cache de metadados (TTL 1h)
status: pending
- id: hf-validation
content: Adicionar validação SHA256 obrigatória quando disponível, retry logic com resume e rate limiting (429)
status: pending
- id: hf-rate-limiting
content: Implementar tratamento de rate limiting com Retry-After header e backoff exponencial
status: pending
- id: llama-cpp-deps
content: Adicionar llama_cpp 0.4 no Cargo.toml com features condicionais por OS + feature flag dev-no-llama
status: pending
- id: llama-deps-parsing
content: Adicionar dependências gguf 0.1 e lru 0.12 para parsing de metadados e cache LRU
status: pending
- id: llama-state
content: Criar LlamaCppState com LRU cache (limite 3 modelos) para prevenir memory leaks
status: pending
- id: models-dir-structure
content: Implementar get_models_dir() com estrutura hierárquica cross-platform (repo_owner/repo_name/quantization)
status: pending
- id: llama-chat-stream
content: Implementar chat_stream_native() com streaming de tokens via llama-cpp-rs
status: pending
- id: llama-params
content: Adicionar configuração de parâmetros (n_gpu_layers, n_ctx, threads) no settings store
status: pending
- id: local-scan
content: Implementar list_local_gguf_models() com scan recursivo e parsing de metadados via crate gguf
status: pending
- id: local-cache
content: Adicionar cache de metadados de modelos locais para evitar re-scan frequente
status: pending
- id: quantization-regex
content: "Implementar regex corrigido para extração de quantização: [._-](Q\\d+_?[KM]?_?[MS]?|[FI]P?\\d+)[._-]"
status: pending
- id: vram-detection
content: Adicionar detecção de VRAM disponível via sysinfo antes de carregar modelos grandes
status: pending
- id: concurrent-downloads
content: Implementar semáforo para limitar downloads paralelos a 2 simultâneos
status: pending
- id: ui-runtime-toggle
content: Criar toggle de runtime (Ollama/llama.cpp) nas configurações com indicador visual
status: pending
- id: ui-hf-selector
content: Implementar seletor de modelos Hugging Face com busca e filtros de quantização
status: pending
- id: ui-integration
content: Integrar runtime selection no use-chat.ts para alternar entre chat_stream e chat_stream_native
status: pending
isProject: false
---
# Plano de Implementação: Integração llama.cpp e Download Hugging Face
## Contexto Arquitetural
O projeto atualmente usa Ollama como runtime de modelos via HTTP (`http://localhost:11434`). O chat streaming é feito através de `chat_stream` que chama `OllamaClient`. Modelos são baixados via `pull_model` que usa a API do Ollama. Existe suporte básico para instalar modelos GGUF locais via `install_gguf_model`.
**Decisão arquitetural:** Implementar runtime dual (Ollama + llama.cpp nativo) com abstração no backend Rust para permitir alternância transparente no frontend.
## Estrutura de Armazenamento
- **Modelos Ollama:** `~/.ollama/models` (Linux/Mac) ou `%USERPROFILE%\.ollama\models` (Windows)
- **Modelos llama.cpp nativo:** Estrutura hierárquica em `{app_data_dir}/models/gguf/{repo_owner}/{repo_name}/{quantization}/model.gguf`
- Exemplo: `~/.local/share/OllaHub/models/gguf/TheBloke/Mistral-7B-Instruct-v0.2-GGUF/Q4_K_M/mistral-7b-instruct-v0.2.Q4_K_M.gguf`
- **Cache de metadados:** `{app_data_dir}/cache/huggingface_models.json`
- **Cross-platform paths:**
- Windows: `%APPDATA%\Roaming\com.ollahub.app\models\gguf`
- Linux: `~/.local/share/com.ollahub.app/models/gguf`
- macOS: `~/Library/Application Support/com.ollahub.app/models/gguf`
## Fase 1: Backend - Download Direto Hugging Face
### 1.1 Módulo de Download (`src-tauri/src/huggingface_download.rs`)
**Contrato técnico:**
```rust
pub struct HuggingFaceModel {
pub id: String,
pub filename: String,
pub size: u64,
pub quantization: String, // Q4_K_M, Q5_K_S, etc.
pub sha256: Option<String>,
}
pub async fn download_gguf_model(
app_handle: AppHandle,
model_id: String,
filename: String,
quantization_filter: Option<String>,
window: Option<Window>,
) -> Result<PathBuf, String>
```
**Implementação:**
- Usar `reqwest` com streaming (já presente no `Cargo.toml`)
- API Hugging Face: `https://huggingface.co/api/models/{model_id}/tree/main`
- Download direto: `https://huggingface.co/{model_id}/resolve/main/{filename}`
- Emitir eventos `download-progress` (reutilizar estrutura existente de `pull_model_impl_window`)
- **Validação SHA256 obrigatória** quando disponível no Hugging Face (usar `sha2` já presente)
- Retry logic com backoff exponencial (3 tentativas)
- **Rate limiting:** Tratar 429 Too Many Requests com `Retry-After` header
- **Resume de download:** Verificar arquivo parcial e continuar de onde parou
**Arquivos:**
- Criar `src-tauri/src/huggingface_download.rs`
- Adicionar comando `download_gguf_model` em `lib.rs`
### 1.2 Busca de Modelos (`list_huggingface_gguf_models`)
**Contrato:**
```rust
pub async fn list_huggingface_gguf_models(
query: Option<String>,
quantization: Option<String>,
) -> Result<Vec<HuggingFaceModel>, String>
```
**Implementação:**
- Usar API pública do Hugging Face: `https://huggingface.co/api/models?search={query}&filter=gguf`
- Filtrar por arquivos `.gguf` no tree
- Cache local com TTL de 1 hora (evitar requests repetidos)
- **Extrair quantização do filename** (regex corrigido: `[._-](Q\d+_?[KM]?_?[MS]?|[FI]P?\d+)[._-]`)
- Suporta: `Q4_K_M`, `Q5_0`, `F16`, `FP32`, etc.
**Arquivos:**
- Adicionar função em `huggingface_download.rs`
- Comando `list_huggingface_gguf_models` em `lib.rs`
### 1.3 Cache de Metadados
**Estrutura:**
- Arquivo JSON em `{app_data_dir}/cache/huggingface_models.json`
- Estrutura: `{ "models": [...], "last_updated": "ISO8601" }`
- Atualizar cache apenas se `last_updated` > 1 hora
## Fase 2: Backend - Integração llama.cpp
### 2.1 Dependências (`Cargo.toml`)
**Correção crítica:** Usar `llama_cpp` (edgenai) versão 0.4 em vez de `llama-cpp-rs` 0.2
```toml
[features]
default = ["llama-cpp"]
llama-cpp = ["dep:llama_cpp"]
dev-no-llama = [] # Skip llama.cpp em dev builds (builds 30-45 min podem ser lentos)
[dependencies]
llama_cpp = { version = "0.4", optional = true }
# Features condicionais por OS (Metal/CUDA são mutuamente exclusivos)
[target.'cfg(target_os = "macos")'.dependencies]
llama_cpp = { version = "0.4", features = ["metal"], optional = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
llama_cpp = { version = "0.4", features = ["cuda"], optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
llama_cpp = { version = "0.4", features = ["cuda"], optional = true }
# Dependência para parsing de metadados GGUF
gguf = "0.1"
lru = "0.12" # Para LRU cache de modelos carregados
```
**Build considerations:**
- `llama_cpp` compila `llama.cpp` via build script
- **Tempo de build:** 30-45 min em máquinas mais fracas (não 10-15 min)
- Feature flag `dev-no-llama` permite builds rápidos em desenvolvimento
- Requer CMake/Ninja instalados (documentar no README)
### 2.2 Gerenciamento de Estado (`AppState`)
**Estrutura (com LRU cache para prevenir memory leaks):**
```rust
use lru::LruCache;
pub struct LlamaCppState {
models: Arc<Mutex<LruCache<String, LlamaModel>>>, // Limite de 3 modelos
default_params: LlamaParams,
}
impl LlamaCppState {
pub fn new() -> Self {
Self {
models: Arc::new(Mutex::new(LruCache::new(3.try_into().unwrap()))),
default_params: LlamaParams::default(),
}
}
}
pub struct LlamaModel {
path: PathBuf,
context: Option<LlamaContext>, // Lazy loading
metadata: ModelMetadata,
}
```
**Arquivos:**
- Criar `src-tauri/src/llama_cpp_state.rs`
- Registrar state em `main()` via `.manage()`
### 2.3 Comando de Chat Streaming (`chat_stream_native`)
**Contrato:**
```rust
#[command]
async fn chat_stream_native(
window: Window,
app_handle: AppHandle,
session_id: Option<String>,
messages: Vec<Message>,
model_path: String,
system_prompt: Option<String>,
params: Option<LlamaParams>,
) -> Result<String, String>
```
**Implementação:**
- Carregar modelo lazy (verificar se já está em `AppState`)
- Usar `llama-cpp-rs` para streaming de tokens
- Emitir eventos `chat-token` (mesmo formato que `chat_stream` atual)
- Reutilizar lógica de salvamento em DB existente
- Parâmetros: `n_gpu_layers`, `n_ctx`, `threads` (configuráveis via settings)
**Arquivos:**
- Criar `src-tauri/src/llama_cpp_chat.rs`
- Comando em `lib.rs`
- Integrar com `ProcessingState` existente
### 2.4 Configuração de Parâmetros
**Settings store (frontend):**
- Adicionar `llamaCppParams` em `settings-store.ts`:
```typescript
llamaCppParams: {
nGpuLayers: number;
nCtx: number;
threads: number;
}
```
**Backend:**
- Struct `LlamaParams` serializável
- Valores padrão: `n_gpu_layers: 0` (CPU), `n_ctx: 4096`, `threads: auto`
## Fase 3: Backend - Listagem Local
### 3.1 Scan de Arquivos GGUF (`list_local_gguf_models`)
**Contrato:**
```rust
#[command]
fn list_local_gguf_models(app_handle: AppHandle) -> Result<Vec<LocalGgufModel>, String>
```
**Implementação:**
- Scan recursivo em `{app_data_dir}/models/gguf/` (estrutura hierárquica)
- Usar `walkdir` (já presente)
- **Parsing de metadados GGUF usando crate `gguf`:**
```rust
use gguf::GGUFFile;
pub fn extract_gguf_metadata(path: &Path) -> Result<GgufMetadata, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let gguf = GGUFFile::parse(file).map_err(|e| e.to_string())?;
Ok(GgufMetadata {
architecture: gguf.header.metadata.get("general.architecture"),
quantization: gguf.header.metadata.get("general.quantization_version"),
parameter_count: gguf.header.metadata.get("general.parameter_count"),
})
}
```
- Extrair metadados:
- Tamanho do arquivo
- Quantização do filename (regex corrigido)
- **Arquitetura via parsing de header GGUF** (não inferir do filename)
- Cache de metadados para evitar re-scan frequente
**Estrutura de retorno:**
```rust
pub struct LocalGgufModel {
pub path: String,
pub name: String,
pub size: u64,
pub quantization: Option<String>,
pub architecture: Option<String>,
}
```
**Arquivos:**
- Função em `lib.rs` ou módulo separado `src-tauri/src/local_models.rs`
### 3.2 Detecção Automática
- Ao baixar modelo do Hugging Face, adicionar à lista local automaticamente
- Verificar integridade (tamanho, extensão) antes de listar
## Fase 4: Frontend - UI Experimental
### 4.1 Settings Store - Runtime Selection
**Modificações em `store/settings-store.ts`:**
```typescript
runtime: 'ollama' | 'llama-cpp';
llamaCppParams: { nGpuLayers: number; nCtx: number; threads: number };
setRuntime: (runtime: 'ollama' | 'llama-cpp') => void;
setLlamaCppParams: (params: Partial<LlamaCppParams>) => void;
```
### 4.2 Toggle de Runtime (`components/settings/`)
**Componente:**
- Toggle switch "Usar llama.cpp nativo"
- **Indicador visual melhorado** com ícone + tooltip:
```tsx
<Badge variant={runtime === 'ollama' ? 'default' : 'secondary'}>
{runtime === 'ollama' ? (
<>🦙 Ollama</>
) : (
<>⚡ llama.cpp (nativo)</>
)}
</Badge>
<Tooltip>
<TooltipContent>
{runtime === 'ollama'
? 'Usando Ollama como runtime (requer servidor externo)'
: 'Usando llama.cpp nativo (mais rápido, sem servidor externo)'}
</TooltipContent>
</Tooltip>
```
- Desabilitar se nenhum modelo GGUF local disponível
**Arquivos:**
- Criar ou modificar `components/settings/runtime-settings-panel.tsx`
### 4.3 Seletor de Modelos Hugging Face
**Componente:**
- Input de busca
- Lista de modelos com filtros de quantização (Q4, Q5, Q8)
- Botão de download com progresso
- Integrar com `DownloadContext` existente
**Arquivos:**
- Criar `components/chat/huggingface-model-selector.tsx`
- Integrar em `components/chat/model-selector.tsx`
### 4.4 Página de Comparação
**Componente experimental:**
- Side-by-side: Ollama vs llama.cpp
- Mesmo prompt, comparar latência e qualidade
- Métricas: tokens/s, tempo total, uso de VRAM
**Arquivos:**
- Criar `app/benchmark/page.tsx` (opcional, pode ser feature flag)
### 4.5 Integração no Chat
**Modificações em `hooks/use-chat.ts`:**
- Verificar `runtime` do settings store
- Chamar `chat_stream` ou `chat_stream_native` conforme runtime
- Manter compatibilidade com código existente
**Arquivos:**
- Modificar `hooks/use-chat.ts`
- Atualizar `app/chat/page.tsx` se necessário
## Decisões Técnicas
### Abordagem Ingênua vs Sênior
| Aspecto | Ingênua | Sênior (Escolhida) |
|---------|---------|-------------------|
| Download | Polling de status | Streaming com eventos Tauri |
| Estado de Modelos | Sem cache | **LRU cache com limite de 3 modelos** |
| Integração Chat | Duplicar código | Abstração via enum Runtime |
| Validação | Apenas tamanho | **SHA256 obrigatório quando disponível** + tamanho + **parsing header GGUF** |
| Build llama.cpp | Sempre compilar | **Feature flags condicionais por OS + dev-no-llama** |
| Rate Limiting | Sem tratamento | **Backoff + Retry-After header** |
| Parsing Metadata | Inferir do filename | **Crate `gguf` para parsing correto** |
### Trade-offs
- **Performance:** llama.cpp nativo pode ser 10-30% mais rápido que Ollama (sem overhead HTTP)
- **Manutenção:** Duplicar lógica de chat aumenta complexidade; abstração Runtime reduz duplicação
- **VRAM:** llama.cpp permite controle fino de `n_gpu_layers`; Ollama gerencia automaticamente
### Modos de Falha
1. **Gargalo VRAM:** Modelos grandes podem não carregar; implementar fallback para CPU
- **Solução:** Usar `sysinfo` crate para detectar VRAM disponível antes de carregar
2. **Download interrompido:** Retry logic com resume (verificar se arquivo parcial existe)
3. **Build llama.cpp falha:** Feature flag `dev-no-llama` para desabilitar em desenvolvimento
4. **Latência de rede:** Cache agressivo de metadados do Hugging Face
5. **Rate limiting Hugging Face:** Tratar 429 com `Retry-After` header
6. **Concurrent downloads:** Limitar a 2 downloads paralelos com semáforo
7. **Memory leaks:** LRU cache com limite de 3 modelos previne crescimento indefinido
### Antipadrões a Evitar
- Não fazer polling de status de download (usar eventos)
- Não carregar todos os modelos na inicialização (lazy loading)
- Não duplicar lógica de streaming (abstrair Runtime)
- Não hardcodar paths (usar `app_data_dir`)
## Ordem de Implementação (Reordenada)
1. **Estrutura de diretórios e AppState** (fundação)
- Criar `get_models_dir()` com paths cross-platform
- Implementar `LlamaCppState` com LRU cache
2. **Listagem local de GGUF** (valida estrutura)
- `list_local_gguf_models()` com parsing via crate `gguf`
- Cache de metadados
3. **Download Hugging Face** (adiciona modelos)
- `download_gguf_model()` com SHA256 obrigatório
- Rate limiting e retry logic
4. **Parsing de metadados GGUF** (enriquece listagem)
- Integrar crate `gguf` para extrair arquitetura
5. **Integração llama.cpp** (runtime)
- `chat_stream_native()` com lazy loading
- Detecção de VRAM via `sysinfo`
6. **UI experimental** (camada final)
- Toggle de runtime com indicador melhorado
- Integração no `use-chat.ts`
## Validação
**Comandos de teste (Windows PowerShell):**
```powershell
# Verificar modelos baixados
Get-ChildItem "$env:APPDATA\OllaHub\models\gguf" -Recurse -Filter "*.gguf"
# Testar download (via Tauri command)
# Usar DevTools do app para invocar: download_gguf_model
```
**Comandos de teste (Linux Bash):**
```bash
# Verificar modelos baixados
find ~/.local/share/com.ollahub.app/models/gguf -name "*.gguf"
# Testar build llama.cpp (com feature flag)
cargo build --features llama-cpp # Build completo
cargo build --no-default-features --features dev-no-llama # Build rápido (dev)
# Verificar VRAM disponível (se NVIDIA)
nvidia-smi --query-gpu=memory.free --format=csv
```
## Correções Críticas Aplicadas
### 1. Crate llama.cpp
- ✅ Alterado de `llama-cpp-rs = "0.2"` para `llama_cpp = "0.4"` (edgenai, mais estável)
### 2. Feature Flags
- ✅ Features condicionais por OS (Metal/CUDA mutuamente exclusivos)
- ✅ Feature flag `dev-no-llama` para builds rápidos em desenvolvimento
### 3. Parsing de Metadados
- ✅ Adicionada dependência `gguf = "0.1"` para parsing correto de headers GGUF
- ✅ Removida inferência de arquitetura do filename
### 4. Memory Management
- ✅ Substituído `HashMap` por `LruCache` com limite de 3 modelos
### 5. Validação SHA256
- ✅ Tornada obrigatória quando disponível no Hugging Face
### 6. Rate Limiting
- ✅ Implementado tratamento de 429 com `Retry-After` header
### 7. Estrutura de Diretórios
- ✅ Hierárquica: `{repo_owner}/{repo_name}/{quantization}/model.gguf`
### 8. Regex de Quantização
- ✅ Corrigido: `[._-](Q\d+_?[KM]?_?[MS]?|[FI]P?\d+)[._-]`
### 9. Cross-Platform Paths
- ✅ Validação de `app_data_dir` para Windows/Linux/macOS
### 10. Indicador de Runtime
- ✅ Melhorado com ícone + tooltip explicativo
### 11. Ordem de Implementação
- ✅ Reordenada considerando dependências técnicas
### 12. Riscos Adicionais
- ✅ VRAM detection via `sysinfo`
- ✅ Limite de downloads paralelos (semáforo)
- ✅ Documentação de requisitos (CMake/Ninja)
+281
View File
@@ -0,0 +1,281 @@
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: 9
- 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: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: './src-tauri -> target'
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2.9" --locked
- 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
shell: bash
run: |
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
else
pnpm install
fi
- name: Generate changelog
run: pnpm generate-changelog
- name: Build frontend (Next.js)
shell: bash
env:
NODE_OPTIONS: ${{ matrix.platform == 'macos-latest' && matrix.target == 'aarch64-apple-darwin' && '--max-old-space-size=4096' || '--max-old-space-size=2048' }}
run: |
echo "Building Next.js frontend with NODE_OPTIONS=${NODE_OPTIONS}"
pnpm build
if [ ! -d "out" ]; then
echo "Error: Frontend build failed - 'out' directory not found"
exit 1
fi
echo "Frontend build successful - 'out' directory exists"
<<<<<<< HEAD
- name: Prepare bundle resources (Windows only)
if: matrix.platform == 'windows-latest'
shell: powershell
run: |
# Criar diretórios
New-Item -ItemType Directory -Force -Path "src-tauri/bundle/ollama" | Out-Null
New-Item -ItemType Directory -Force -Path "src-tauri/bundle/models/embeddings" | Out-Null
# Baixar instalador do Ollama (se não existir)
$ollamaPath = "src-tauri/bundle/ollama/OllamaSetup.exe"
if (!(Test-Path $ollamaPath)) {
Write-Host "Downloading Ollama installer..."
Invoke-WebRequest -Uri "https://ollama.com/download/OllamaSetup.exe" -OutFile $ollamaPath -UseBasicParsing
}
# Baixar modelos de embeddings (se não existirem)
$modelPath = "src-tauri/bundle/models/embeddings/all-MiniLM-L6-v2.onnx"
if (!(Test-Path $modelPath)) {
Write-Host "Downloading ONNX embedding model (~90MB, this may take a while)..."
Invoke-WebRequest -Uri "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx" -OutFile $modelPath -UseBasicParsing
}
$tokenizerPath = "src-tauri/bundle/models/embeddings/tokenizer.json"
if (!(Test-Path $tokenizerPath)) {
Write-Host "Downloading tokenizer..."
Invoke-WebRequest -Uri "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json" -OutFile $tokenizerPath -UseBasicParsing
}
# Baixar e extrair ONNX Runtime DLL
$ortDllPath = "src-tauri/bundle/models/embeddings/onnxruntime.dll"
if (!(Test-Path $ortDllPath)) {
$ortZipPath = "src-tauri/bundle/models/embeddings/onnxruntime.zip"
if (!(Test-Path $ortZipPath)) {
Write-Host "Downloading ONNX Runtime library (~50MB)..."
Invoke-WebRequest -Uri "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip" -OutFile $ortZipPath -UseBasicParsing
}
Write-Host "Extracting ONNX Runtime DLL..."
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($ortZipPath)
foreach ($entry in $zip.Entries) {
if ($entry.Name -eq "onnxruntime.dll") {
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $ortDllPath, $true)
Write-Host "Extracted onnxruntime.dll"
break
}
}
$zip.Dispose()
Remove-Item $ortZipPath -ErrorAction SilentlyContinue
}
Write-Host "Bundle resources prepared successfully"
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
- name: Build Tauri app
run: |
cd src-tauri
cargo build --release --target ${{ matrix.target }}
- name: Build Tauri bundle
shell: bash
env:
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_UPDATER_PRIVATE_KEY }}
NODE_OPTIONS: ${{ matrix.platform == 'macos-latest' && matrix.target == 'aarch64-apple-darwin' && '--max-old-space-size=4096' || '--max-old-space-size=2048' }}
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: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install frontend dependencies
shell: bash
run: |
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
else
pnpm install
fi
- 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
shell: bash
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
+11 -10
View File
@@ -39,14 +39,15 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
~/.tauri/ollahub.key
# database
database/*.db
database/extensions/*.so
database/extensions/*.dll
database/extensions/*.dylib
# mcp servers installation directory
/mcp-servers/
/.cursor
# tauri build artifacts
src-tauri/target/
src-tauri/bundle/
*.exe
*.msi
*.AppImage
*.deb
*.dmg
OllamaSetup.exe
src-tauri/bundle/ollama/
+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
+27
View File
@@ -0,0 +1,27 @@
OllaHub Community License (v1.0)
Copyright (c) 2024 Evandro Fonseca Junior (evandrodevbr) Website: https://evandro.dev.br
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. GRANT OF LICENSE Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, publish, distribute, and sublicense copies of the Software, subject strictly to the conditions and limitations set forth below.
2. ATTRIBUTION REQUIREMENT (CREDITWARE) This license explicitly requires correct attribution as a condition of use. a) Source Code & Distribution: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. b) Public Display: Any redistribution, fork, derivative work, video content, article, social media post, or public demonstration involving this Software MUST include visible and clear credits to the original author (Evandro Fonseca Junior) and a direct link to https://evandro.dev.br. c) UI Integrity: Removal, obscuring, or modification of copyright notices, author links, metadata, or "About" screens within the Software's user interface is strictly prohibited.
3. NON-COMMERCIAL & ANTI-MONETIZATION RESTRICTIONS This license distinguishes between "Internal Use" and "Commercial Distribution." a) Prohibition of Sale: You may NOT sell, rent, lease, or sub-license the Software for a fee. You may not bundle this Software within a paid product or service without express written permission from the Copyright Holder. b) No Monetization Barriers: You may NOT use the Software as a mechanism for monetization, including but not limited to "freemium" models, paywalls, or "pay-for-speed" download gates. c) Permitted Internal Use: The use of this Software internally within a commercial organization for productivity, research, or development purposes (e.g., employees using the tool to perform their daily tasks) is EXPLICITLY PERMITTED and encouraged, provided the Software itself is not the product being sold.
4. DONATION POLICY The Software is developed and maintained through voluntary contributions. a) Official Channels Only: No third-party entity is authorized to solicit or collect donations on behalf of OllaHub or its author. b) Direct Support: All financial contributions must be directed exclusively to the official channels listed at https://evandro.dev.br. Soliciting donations for a fork or redistribution of this Software without significant and distinct added value is prohibited.
5. DISCLAIMER OF WARRANTY & LIABILITY THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
6. SPECIFIC DISCLAIMER: AI & WEB SCRAPING The Software interacts with Large Language Models (LLMs) and performs web scraping operations. By using this Software, you acknowledge and agree that: a) The authors are not responsible for the accuracy, hallucinations, or bias of content generated by LLMs. b) You are solely responsible for ensuring that your use of the web scraping features complies with the Terms of Service (ToS) of third-party websites and applicable local laws. c) You are responsible for maintaining data privacy and security when processing sensitive information.
7. COMMERCIAL LICENSING For usage scenarios that fall outside these terms (e.g., white-labeling, redistribution in paid suites, or custom enterprise agreements), please contact the author for a commercial license:
Website: https://evandro.dev.br
+192 -22
View File
@@ -1,36 +1,206 @@
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">
<h1>🧠 OllaHub</h1>
<p><strong>Local-first LLM workspace with deep research capabilities and native OS integration.</strong></p>
## Getting Started
<p align="center">
<a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/Rust-1.77.2-orange.svg?style=flat-square&logo=rust" alt="Rust"></a>
<a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-2.9.2-24C8DB.svg?style=flat-square&logo=tauri" alt="Tauri"></a>
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/Next.js-16.0.3-black.svg?style=flat-square&logo=next.js" alt="Next.js"></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.0-blue.svg?style=flat-square&logo=typescript" alt="TypeScript"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-Custom-blue.svg?style=flat-square" alt="License"></a>
</p>
First, run the development server:
<br>
</div>
---
## 🌟 Why OllaHub?
OllaHub is a powerful desktop application that orchestrates local Large Language Models (LLMs) through **Ollama**, providing a private, offline-capable workspace for research, analysis, and conversation.
Unlike standard chat interfaces, OllaHub executes **complex research workflows** by decomposing queries, performing parallel web scraping, and synthesizing context from multiple sources before generating responses—all while keeping your data 100% local and private.
---
## ✨ Key Features
### 🔍 Deep Research Pipeline
OllaHub doesn't just answer; it researches. Our multi-stage pipeline includes:
1. **Query Decomposition:** Uses the LLM to break complex queries into 3-5 atomic, search-optimized sub-queries.
2. **Parallel Scraping:** Executes concurrent searches using headless Chrome and the DuckDuckGo metadata API.
3. **Knowledge Aggregation:** Collects, deduplicates, and cleans content from multiple sources.
4. **Context Condensation:** Applies relevance scoring (ONNX embeddings) and summarization to fit context within token limits.
5. **Validation:** Verifies knowledge base sufficiency before response generation, with safety fallbacks.
### 🛡️ Local-First & Privacy-Focused
* **Ollama Integration:** Direct subprocess management for seamless local model execution.
* **Local Semantic Search:** Powered by ONNX Runtime (`all-MiniLM-L6-v2` 384-dimensional embeddings) with no external API calls.
* **Hybrid Storage:** Blazing fast SQLite (FTS5 for full-text search) with JSON fallbacks for backward compatibility and data safety.
### 🔌 MCP (Model Context Protocol) Integration
Extensible by design. Connect external tools via MCP servers:
* **Tool Discovery:** Automatic enumeration of available tools from running servers.
* **Process Management:** Full lifecycle control (start, stop, restart) with health monitoring.
* **Tool Execution:** Runtime invocation of tools with direct result injection into the LLM context.
### ⚡ Reactive & Customizable UI
Built with **React 19, Next.js 16, and Tailwind CSS**. Features smooth animations (Framer Motion), accessible primitives (Radix UI/Shadcn), resizable panels, and lightweight state management (Zustand).
---
## 🏗️ System Architecture
<details>
<summary><b>Click to view Architecture Diagram</b></summary>
```mermaid
graph TB
subgraph Frontend["Frontend (Next.js)"]
UI[React Components]
Hooks[Custom Hooks]
Store[Zustand Store]
end
subgraph Bridge["Tauri Bridge"]
Commands[Tauri Commands]
Events[Event System]
end
subgraph Backend["Rust Core"]
Orchestrator[Orchestrator]
Scraper[Web Scraper]
DB[(SQLite + FTS5)]
Embeddings[ONNX Embeddings]
Scheduler[Task Scheduler]
MCP[MCP Manager]
end
subgraph External["External Services"]
Ollama[Ollama Subprocess]
Chrome[Headless Chrome]
MCP_Servers[MCP Servers]
end
UI --> Hooks
Hooks --> Store
Store --> Commands
Commands --> Orchestrator
Orchestrator --> Scraper
Orchestrator --> DB
Orchestrator --> Embeddings
Orchestrator --> Scheduler
Orchestrator --> MCP
Scraper --> Chrome
Orchestrator --> Ollama
MCP --> MCP_Servers
Events --> UI
style Frontend fill:#2563eb,color:#fff
style Bridge fill:#7c3aed,color:#fff
style Backend fill:#dc2626,color:#fff
style External fill:#059669,color:#fff
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
</details>
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
---
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## 🚀 Getting Started
## Learn More
### Prerequisites
To learn more about Next.js, take a look at the following resources:
Before you begin, ensure you have the following installed:
- [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.
* **Node.js**: `v20.x` or later
* **Rust**: `v1.77.2` or later
* **pnpm**: Latest version (`npm install -g pnpm`)
* **[Ollama](https://ollama.ai)**: Must be running locally on `http://localhost:11434`
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
### Installation & Development
## Deploy on Vercel
1. **Clone the repository:**
```bash
git clone [https://github.com/evandrodevbr/OllaHub.git](https://github.com/evandrodevbr/OllaHub.git)
cd OllaHub
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
```
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
2. **Install dependencies:**
```bash
pnpm install
```
3. **Run in development mode:**
```bash
pnpm tauri dev
```
*This starts the Next.js server (`localhost:3000`), builds the Rust backend, and launches the Tauri native window.*
### Building for Production
```bash
pnpm tauri build
```
*Compiled binaries will be available in `src-tauri/target/release/bundle/` (.msi, .dmg, .app, .deb, etc.).*
---
## 💻 Tech Stack Deep Dive
<div align="center">
<img src="https://www.google.com/search?q=https://skillicons.dev/icons%3Fi%3Drust,tauri,nextjs,react,ts,tailwind,sqlite" />
</div>
| Layer | Technologies Used | Purpose |
| --- | --- | --- |
| **Core** | Tauri 2.9, Rust 1.77 | Native desktop environment, high-performance system I/O, concurrency (Tokio). |
| **Frontend** | Next.js 16, React 19, TS | SSR-capable UI, Zustand (State), Framer Motion (Animations), Tailwind/Radix. |
| **Database** | SQLite (rusqlite 0.31) | FTS5 virtual tables for lightning-fast full-text search and session storage. |
| **AI / ML** | Ollama, ONNX Runtime | Local LLM execution and 384-dim semantic embeddings (`all-MiniLM-L6-v2`). |
| **Scraping** | headless_chrome, scraper | Parallel web automation, DOM parsing, and readability extraction. |
---
## 🗺️ Roadmap
* [ ] **Vector Store Integration**: Transition from SQLite BLOB embeddings to a dedicated vector DB (LanceDB or Qdrant).
* [ ] **Advanced RAG**: Multi-modal retrieval supporting images, PDFs, and local documents.
* [ ] **Cloud Sync**: Optional E2E encrypted synchronization of chat sessions across devices.
* [ ] **Custom Plugin System**: Extensible architecture for user-defined research workflows.
* [ ] **Model Fine-Tuning UI**: Native interface for training custom models on personal knowledge bases.
---
## ⚖️ License & Terms
OllaHub is released under a custom license combining freeware and creditware models. See the [LICENSE](https://www.google.com/search?q=LICENSE) file for full legal terms.
**TL;DR:**
* ✅ **You may:** Use freely for personal/educational/internal use, modify, and fork the project.
* ✅ **You must:** Give visible credit to **Evandro Fonseca Junior** and link to [evandro.dev.br](https://evandro.dev.br). Keep copyright notices intact.
* ❌ **You may not:** Sell the software, bundle it in paid products, or monetize it without explicit written permission.
For commercial licensing inquiries, please contact me via [evandro.dev.br](https://evandro.dev.br).
---
<div align="center">
<p>Built by <a href="https://github.com/evandrodevbr">Evandro Fonseca Junior</a></p>
</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);
}
}
-66
View File
@@ -1,66 +0,0 @@
import { NextRequest } from "next/server";
import { pullModel, ensureOllamaAvailable } from "@/lib/ollama";
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;
}
const response = await pullModel(model);
for await (const chunk of response) {
controller.enqueue(
new TextEncoder().encode(JSON.stringify(chunk) + "\n"),
);
}
controller.enqueue(
new TextEncoder().encode(
JSON.stringify({ status: "success" }) + "\n",
),
);
controller.close();
} catch (error) {
console.error("Pull streaming error:", error);
controller.enqueue(
new TextEncoder().encode(
JSON.stringify({ error: "Failed to pull model" }) + "\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 });
}
}
-22
View File
@@ -1,22 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { searchRemoteModels } from "@/lib/ollama";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const query = searchParams.get("q") || undefined;
const remoteModels = await searchRemoteModels(query);
return NextResponse.json({
success: true,
models: remoteModels,
});
} catch (error) {
console.error("Error searching remote models:", error);
return NextResponse.json(
{ error: "Failed to search remote models" },
{ 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 }
);
}
}
+1654
View File
File diff suppressed because it is too large. Load diff
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+378 -127
View File
@@ -1,164 +1,415 @@
@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;
}
/* Utility class para ocultar scrollbar */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
/* 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; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+52 -8
View File
@@ -1,13 +1,34 @@
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";
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
<<<<<<< HEAD
import { DownloadProvider } from "@/contexts/download-context";
import { GlobalDownloadStatusBar } from "@/components/global-download-status-bar";
import { OllamaHealthProvider } from "@/contexts/ollama-health-context";
import { ServiceInterruptionOverlay } from "@/components/ui/service-interruption-overlay";
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
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",
icons: {
icon: "/icon.png",
apple: "/icon.png",
},
};
export default function RootLayout({
@@ -16,9 +37,32 @@ 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
>
<<<<<<< HEAD
<OllamaHealthProvider>
<DownloadProvider>
<KeyboardShortcuts />
{children}
<AppUpdaterProvider />
<GlobalDownloadStatusBar />
<ServiceInterruptionOverlay />
</DownloadProvider>
</OllamaHealthProvider>
=======
<KeyboardShortcuts />
{children}
<AppUpdaterProvider />
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
</ThemeProvider>
</body>
</html>
);
+238 -17
View File
@@ -1,21 +1,242 @@
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: [] };
<<<<<<< HEAD
import { TitleBar } from "@/components/titlebar";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, startTransition, useRef } from "react";
import { useSettingsStore } from "@/store/settings-store";
import { invoke } from "@tauri-apps/api/core";
export default function Home() {
=======
import { useSetupCheck } from "@/hooks/use-setup-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, startTransition } from "react";
export default function Home() {
const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck();
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
const router = useRouter();
const { setSetupCompleted } = useSettingsStore();
const [isHydrated, setIsHydrated] = useState(false);
const [isChecking, setIsChecking] = useState(true);
// Refs para evitar loops e execuções múltiplas
const hasCheckedRef = useRef(false);
const isRedirectingRef = useRef(false);
<<<<<<< HEAD
// Hydration check: aguardar Zustand carregar do localStorage
useEffect(() => {
setIsHydrated(true);
}, []);
// Guarda de roteamento: verificação de integridade do Ollama + flag persistente
// Prioridade: Integridade do Sistema > Flag de Setup
useEffect(() => {
if (!isHydrated) return;
if (hasCheckedRef.current) return; // Proteção contra múltiplas execuções
const performCheck = async () => {
if (isRedirectingRef.current) return; // Evitar múltiplos redirecionamentos
setIsChecking(true);
try {
// 1. Verificação Real do Sistema (Fonte da Verdade)
const isOllamaIntact = await invoke<boolean>('verify_ollama_integrity_command');
// Ler isSetupCompleted diretamente do store (não usar como dependência)
const currentSetupStatus = useSettingsStore.getState().isSetupCompleted;
if (!isOllamaIntact) {
// Se o motor não existe, força o setup, não importa o que o storage diga
console.warn("Integridade do Ollama falhou. Redirecionando para Setup.");
if (!isRedirectingRef.current) {
isRedirectingRef.current = true;
// Resetar flag de setup se Ollama não está íntegro (ANTES de redirecionar)
setSetupCompleted(false);
// Pequeno delay para garantir que o estado seja persistido no localStorage
await new Promise(resolve => setTimeout(resolve, 100));
startTransition(() => {
router.replace('/setup');
});
}
return;
}
// 2. Verificação de Fluxo de UI (apenas se Ollama está OK)
if (!isRedirectingRef.current) {
isRedirectingRef.current = true;
if (currentSetupStatus) {
startTransition(() => {
router.replace('/chat');
});
} else {
startTransition(() => {
router.replace('/setup');
});
}
}
} catch (e) {
console.error("Falha crítica no check de inicialização", e);
// Fallback seguro: sempre ir para setup em caso de erro
if (!isRedirectingRef.current) {
isRedirectingRef.current = true;
setSetupCompleted(false);
// Pequeno delay para garantir que o estado seja persistido no localStorage
await new Promise(resolve => setTimeout(resolve, 100));
startTransition(() => {
router.replace('/setup');
});
}
} finally {
setIsChecking(false);
hasCheckedRef.current = true; // Marcar como executado
}
};
performCheck();
}, [isHydrated, router, setSetupCompleted]);
// Enquanto não hidratou ou está verificando, retornar skeleton simples
if (!isHydrated || isChecking) {
return (
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
<TitleBar />
<div className="flex-1 flex items-center justify-center pt-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-foreground">
{!isHydrated ? 'Carregando...' : 'Verificando sistema...'}
</span>
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
</div>
=======
// Redirecionar automaticamente se tudo estiver pronto
useEffect(() => {
if (status === 'ready') {
const setupComplete = localStorage.getItem("ollahub_setup_complete");
// Se setup não foi marcado como completo, marcar agora
if (!setupComplete) {
localStorage.setItem("ollahub_setup_complete", "true");
}
// Redirecionar para chat imediatamente (startTransition já garante não-bloqueio)
startTransition(() => {
router.push("/chat");
});
return () => clearTimeout(timer);
}
}, [status, router]);
const handleStart = () => {
const setupComplete = localStorage.getItem("ollahub_setup_complete");
if (setupComplete) {
startTransition(() => {
router.push("/chat");
});
} else {
startTransition(() => {
router.push("/setup");
});
}
};
// Mostrar loading durante verificação
if (status === 'checking') {
return (
<div className="flex h-screen w-full items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-foreground">{checkingStep}</span>
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
</div>
</div>
</div>
);
}
}
export default async function Home() {
const { offline, models } = await fetchModels();
return <ClientHome offline={offline} models={models} />;
// Após verificação, mostrar loading de redirecionamento (não deve aparecer por muito tempo)
return (
<<<<<<< HEAD
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
<TitleBar />
<div className="flex-1 flex items-center justify-center pt-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-foreground">Redirecionando...</span>
<span className="text-xs text-muted-foreground">Aguarde um momento...</span>
</div>
</div>
</div>
</div>
=======
<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 Ready State - Redirecionamento automático */}
{status === 'ready' && (
<div className="flex h-screen w-full items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4 animate-in fade-in duration-300">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-sm font-medium text-foreground">Tudo pronto!</span>
<span className="text-xs text-muted-foreground">Redirecionando para o chat...</span>
</div>
</div>
</div>
)}
{/* Layout for Needs Setup State */}
{status === 'needs_setup' && ollamaStatus === 'running' && (
<Hero onStart={handleStart} />
)}
{/* Layout for Stopped State */}
{status === 'needs_setup' && ollamaStatus === '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={recheck} />
</div>
)}
{/* Layout for Not Installed State (Background + Modal) */}
{status === 'needs_ollama' && (
<>
<div className="opacity-20 pointer-events-none blur-sm">
<Hero onStart={() => {}} />
</div>
{/* Hero is shown in background, modal is open */}
<InstallModal
open={true}
onCheckAgain={recheck}
/>
</>
)}
</main>
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
);
}
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
'use client';
<<<<<<< HEAD
import { SetupWizard } from '@/components/setup/setup-wizard';
import { TitleBar } from '@/components/titlebar';
export default function SetupPage() {
=======
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";
import { startTransition, useState } from "react";
export default function SetupPage() {
const { specs, loading, gpus } = useHardware();
const router = useRouter();
const [isNavigating, setIsNavigating] = useState(false);
const handleComplete = () => {
setIsNavigating(true);
// Save setup state if needed
localStorage.setItem("ollahub_setup_complete", "true");
// Usar startTransition para navegação não-bloqueante
startTransition(() => {
router.push("/chat");
});
};
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
return (
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
<TitleBar />
<SetupWizard />
</div>
);
}
+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 pt-8">
<div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden">
<div className="mb-4 sm:mb-6">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold truncate">Tarefas Agendadas</h1>
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
Gerencie tarefas que executam automaticamente em segundo plano
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => router.push('/chat')}
className="rounded-lg shrink-0"
title="Voltar para o chat"
>
<X className="w-5 h-5" />
</Button>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-muted-foreground">
Carregando tasks...
</div>
) : tasks.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground mb-6">
Nenhuma task agendada ainda.
</p>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button size="lg" className="w-full max-w-md">
<Plus className="w-5 h-5 mr-2" />
Criar Primeira Task
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Criar Nova Task</DialogTitle>
<DialogDescription>
Configure uma tarefa para executar automaticamente
</DialogDescription>
</DialogHeader>
<TaskFormDialog
onSuccess={() => {
setIsDialogOpen(false);
loadTasks();
}}
/>
</DialogContent>
</Dialog>
</CardContent>
</Card>
) : (
<>
<div className="mb-4">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button size="lg" className="w-full">
<Plus className="w-5 h-5 mr-2" />
Nova Task
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Criar Nova Task</DialogTitle>
<DialogDescription>
Configure uma tarefa para executar automaticamente
</DialogDescription>
</DialogHeader>
<TaskFormDialog
onSuccess={() => {
setIsDialogOpen(false);
loadTasks();
}}
/>
</DialogContent>
</Dialog>
</div>
<div className="grid gap-4">
{tasks.map(task => (
<Card key={task.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
{task.label}
{task.enabled ? (
<Badge variant="default" className="bg-green-500/10 text-green-500 border-green-500/20">
<CheckCircle2 className="w-3 h-3 mr-1" />
Ativa
</Badge>
) : (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
Pausada
</Badge>
)}
</CardTitle>
<CardDescription className="mt-2">
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatCron(task.cron_schedule)}
</span>
<span>Última execução: {formatLastRun(task.last_run)}</span>
</div>
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Label htmlFor={`toggle-${task.id}`} className="text-sm">
{task.enabled ? 'Ativa' : 'Pausada'}
</Label>
<Switch
id={`toggle-${task.id}`}
checked={task.enabled}
onCheckedChange={() => toggleTask(task.id, task.enabled)}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => deleteTask(task.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
<p><strong>Tipo:</strong> {
task.action.type === 'search_and_summarize' ? 'Pesquisar e Resumir' :
task.action.type === 'just_ping' ? 'Notificação' :
'Prompt Customizado'
}</p>
{task.action.query && (
<p><strong>Query:</strong> {task.action.query}</p>
)}
{task.action.model && (
<p><strong>Modelo:</strong> {task.action.model}</p>
)}
</div>
</CardContent>
</Card>
))}
</div>
</>
)}
</div>
</div>
</div>
);
}
function TaskFormDialog({ onSuccess }: { onSuccess: () => void }) {
const [label, setLabel] = useState('');
const [scheduleType, setScheduleType] = useState<'daily' | 'weekly' | 'hourly' | 'custom'>('daily');
const [hour, setHour] = useState('8');
const [minute, setMinute] = useState('0');
const [intervalHours, setIntervalHours] = useState('1');
const [customCron, setCustomCron] = useState('');
const [taskType, setTaskType] = useState<'search_and_summarize' | 'just_ping' | 'custom_prompt'>('search_and_summarize');
const [query, setQuery] = useState('');
const [model, setModel] = useState('llama3.2');
const [maxResults, setMaxResults] = useState(3);
const [message, setMessage] = useState('');
const [customPrompt, setCustomPrompt] = useState('');
const [submitting, setSubmitting] = useState(false);
const generateCron = (): string => {
if (scheduleType === 'custom') {
return customCron;
}
if (scheduleType === 'hourly') {
return `0 */${intervalHours} * * *`;
}
if (scheduleType === 'daily') {
return `${minute} ${hour} * * *`;
}
// Weekly (every Monday at hour:minute)
return `${minute} ${hour} * * 1`;
};
const getCronPreview = (): string => {
const cron = generateCron();
if (scheduleType === 'hourly') {
return `A cada ${intervalHours} hora(s)`;
}
if (scheduleType === 'daily') {
return `Todo dia às ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
}
if (scheduleType === 'weekly') {
return `Toda segunda-feira às ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
}
return cron;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
let action: any;
if (taskType === 'search_and_summarize') {
action = {
type: 'search_and_summarize',
query,
model,
max_results: maxResults,
};
} else if (taskType === 'just_ping') {
action = {
type: 'just_ping',
message,
};
} else {
action = {
type: 'custom_prompt',
prompt: customPrompt,
model,
};
}
await invoke('create_task', {
label,
cronSchedule: generateCron(),
action,
});
onSuccess();
} catch (error) {
console.error('Erro ao criar task:', error);
alert('Erro ao criar task. Verifique o console para mais detalhes.');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="label">Nome da Task</Label>
<Input
id="label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ex: Resumo de Notícias Diário"
required
/>
</div>
<div>
<Label htmlFor="schedule-type">Frequência</Label>
<Select value={scheduleType} onValueChange={(v: any) => setScheduleType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Diário</SelectItem>
<SelectItem value="weekly">Semanal</SelectItem>
<SelectItem value="hourly">A cada X horas</SelectItem>
<SelectItem value="custom">Cron Customizado (Avançado)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Cron Preview */}
<div className="bg-muted p-3 rounded-lg">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">Agendamento:</span>
<span className="text-sm text-muted-foreground">{getCronPreview()}</span>
</div>
<div className="mt-1 text-xs text-muted-foreground font-mono">
Cron: {generateCron()}
</div>
</div>
{scheduleType === 'custom' ? (
<div>
<Label htmlFor="cron">Expressão Cron</Label>
<Input
id="cron"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
placeholder="0 8 * * * (todo dia às 8h)"
required
/>
<p className="text-xs text-muted-foreground mt-1">
Formato: minuto hora dia mês dia-da-semana (ex: 0 8 * * * = todo dia às 8h)
</p>
</div>
) : scheduleType === 'hourly' ? (
<div>
<Label htmlFor="interval-hours">Intervalo (horas)</Label>
<Input
id="interval-hours"
type="number"
min="1"
max="24"
value={intervalHours}
onChange={(e) => setIntervalHours(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground mt-1">
A task será executada a cada {intervalHours} hora(s)
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="hour">Hora (0-23)</Label>
<Input
id="hour"
type="number"
min="0"
max="23"
value={hour}
onChange={(e) => setHour(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="minute">Minuto (0-59)</Label>
<Input
id="minute"
type="number"
min="0"
max="59"
value={minute}
onChange={(e) => setMinute(e.target.value)}
required
/>
</div>
</div>
)}
<div>
<Label htmlFor="task-type">Tipo de Task</Label>
<Select value={taskType} onValueChange={(v: any) => setTaskType(v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="search_and_summarize">Pesquisar e Resumir</SelectItem>
<SelectItem value="just_ping">Apenas Notificação</SelectItem>
<SelectItem value="custom_prompt">Prompt Customizado</SelectItem>
</SelectContent>
</Select>
</div>
{taskType === 'search_and_summarize' && (
<>
<div>
<Label htmlFor="query">Query de Pesquisa</Label>
<Input
id="query"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Ex: notícias sobre inteligência artificial hoje"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="model">Modelo</Label>
<Input
id="model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="llama3.2"
required
/>
</div>
<div>
<Label htmlFor="max-results">Máx. Resultados</Label>
<Input
id="max-results"
type="number"
min="1"
max="10"
value={maxResults}
onChange={(e) => setMaxResults(parseInt(e.target.value))}
required
/>
</div>
</div>
</>
)}
{taskType === 'just_ping' && (
<div>
<Label htmlFor="message">Mensagem</Label>
<Textarea
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Mensagem da notificação"
required
/>
</div>
)}
{taskType === 'custom_prompt' && (
<>
<div>
<Label htmlFor="prompt">Prompt</Label>
<Textarea
id="prompt"
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Seu prompt customizado aqui"
required
className="min-h-[100px]"
/>
</div>
<div>
<Label htmlFor="model-prompt">Modelo</Label>
<Input
id="model-prompt"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="llama3.2"
required
/>
</div>
</>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="submit" disabled={submitting}>
{submitting ? 'Criando...' : 'Criar Task'}
</Button>
</div>
</form>
);
}
-37
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": {}
}
-86
View File
@@ -1,86 +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 { MCPManualInstallModal } from "@/components/mcp/MCPManualInstallModal";
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 [showManualInstall, setShowManualInstall] = useState(false);
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>
)}
<button
onClick={() => setShowManualInstall(true)}
className="flex items-center gap-2 px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors text-sm"
>
<Store className="h-4 w-4" />
Install MCP Manually
</button>
<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)} />
<MCPManualInstallModal
isOpen={showManualInstall}
onClose={() => setShowManualInstall(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}
/>
);
}
-552
View File
@@ -1,552 +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";
import { SettingsModal } from "@/components/chat/SettingsModal";
import { MCPSelector, ActiveMCPBadges } from "@/components/chat/MCPSelector";
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);
// 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]);
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;
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputContent.trim() && !isStreaming) {
handleSendMessage(inputContent.trim());
setInputContent("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
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
};
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();
}
} catch (e) {
console.error("Error parsing pull stream:", e);
}
}
}
} catch (error) {
throw error;
}
};
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={handlePullModel}
disabled={isStreaming}
/>
</div>
<div className="flex items-center gap-2">
<SettingsModal
systemPrompt={systemPrompt}
onSystemPromptChange={handleSystemPromptChange}
selectedModel={selectedModel}
models={models}
/>
<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">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-[var(--foreground)]/60">
<p className="text-lg mb-2">Welcome to Ollahub</p>
<p className="text-sm">Select a model and start chatting!</p>
</div>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{isStreaming && streamingContent && (
<ChatMessage
message={createMessage("assistant", streamingContent)}
isStreaming={true}
/>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input */}
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--background)]">
<form onSubmit={handleSubmit} className="p-4">
{/* Active MCP Badges */}
<ActiveMCPBadges
activeMcps={activeMcps}
mcpNames={mcpNames}
onRemove={handleRemoveMCP}
/>
<div className="flex gap-2">
{/* MCP Selector */}
<MCPSelector
activeMcps={activeMcps}
onToggleMCP={handleToggleMCP}
onClearAll={handleClearAllMCPs}
/>
<textarea
value={inputContent}
onChange={(e) => setInputContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
selectedModel ? "Type a message..." : "Select a model first..."
}
disabled={!selectedModel || isStreaming}
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}
maxLength={2000}
/>
<button
type={isStreaming ? "button" : "submit"}
onClick={isStreaming ? handleStop : undefined}
disabled={
isStreaming ? false : !inputContent.trim() || !selectedModel
}
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" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</form>
</div>
{/* Pull Dialog */}
<ModelPullDialog
isOpen={showPullDialog}
onClose={() => setShowPullDialog(false)}
modelName={modelToPull}
onConfirmPull={handlePullModel}
/>
</div>
);
}
-157
View File
@@ -1,157 +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;
}
export function ChatMessage({
message,
isStreaming = false,
}: ChatMessageProps) {
const isUser = message.role === "user";
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-[80%] relative rounded-lg px-4 py-2 ${
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">
<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}
maxLength={2000}
/>
<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>
);
}
-117
View File
@@ -1,117 +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);
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);
}
};
return (
<div className="w-64 h-full bg-[var(--surface)] border-r border-[var(--border)] flex flex-col">
{/* Header */}
<div className="p-4 border-b border-[var(--border)]">
<button
onClick={onNewConversation}
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"
>
<Plus className="h-4 w-4" />
Nova Conversa
</button>
</div>
{/* Lista de Conversas */}
<div className="flex-1 overflow-y-auto p-2">
{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>
) : (
conversations.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)]/10 text-[var(--accent)]"
: "hover:bg-[var(--background)]"
}`}
>
<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>
);
}
-246
View File
@@ -1,246 +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;
}
export function MCPSelector({
activeMcps,
onToggleMCP,
onClearAll,
}: MCPSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [availableMcps, setAvailableMcps] = useState<MCPForChat[]>([]);
const [loading, setLoading] = useState(false);
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);
};
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(--primary)] text-[var(--primary-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-80 bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-lg z-50 max-h-96 overflow-hidden flex flex-col">
{/* Header */}
<div className="p-3 border-b border-[var(--border)] flex items-center justify-between">
<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 text-red-500 hover:text-red-600 font-medium"
>
Clear All
</button>
)}
</div>
{/* Content */}
<div className="overflow-y-auto flex-1">
{loading ? (
<div className="p-4 text-center text-sm text-[var(--muted-foreground)]">
Loading MCPs...
</div>
) : availableMcps.length === 0 ? (
<div className="p-4 text-center text-sm text-[var(--muted-foreground)]">
No MCPs installed
</div>
) : (
<div className="p-2">
{availableMcps.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(--primary)]/10 border border-[var(--primary)]/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(--muted-foreground)] ml-6">
<Zap className="h-3 w-3" />
<span>{mcp.toolCount} tools available</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(--primary)]/20 text-[var(--primary)] 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 */}
{activeMcps.length > 0 && (
<div className="p-3 border-t border-[var(--border)] bg-[var(--surface)]/50">
<p className="text-xs text-[var(--muted-foreground)]">
AI can use {activeMcps.length} MCP
{activeMcps.length > 1 ? "s" : ""} to help answer your questions
</p>
</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>
);
}
-121
View File
@@ -1,121 +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 }>;
};
export function SettingsModal({
systemPrompt,
onSystemPromptChange,
selectedModel,
models,
}: SettingsModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [tempPrompt, setTempPrompt] = useState(systemPrompt);
const selectedModelData = models.find((m) => m.id === selectedModel);
const handleSave = () => {
onSystemPromptChange(tempPrompt);
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>
</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>
);
}
+252
View File
@@ -0,0 +1,252 @@
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 { DebugConsole, type DebugData } from "./debug-console";
import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
import { useSettingsStore } from "@/store/settings-store";
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
highlightTerm?: string;
highlightIndex?: number;
messageIndex?: number;
debugData?: DebugData;
}
/**
* Componente de mensagem do chat otimizado com React.memo
*
* Renderiza markdown em tempo real durante streaming para melhor UX.
* Usa useMemo para otimizar re-renders e manter performance.
*/
function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex, debugData }: ChatMessageProps) {
const debugMode = useSettingsStore(state => state.debugMode);
// 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]);
// Memoizar conteúdo markdown para otimizar re-renders durante streaming
const markdownContent = useMemo(() => processedContent, [processedContent]);
// 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 de markdown em tempo real durante streaming
<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",
"relative"
)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownRenderers}
>
{markdownContent}
</ReactMarkdown>
<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>
{!isUser && !isStreaming && debugMode && debugData && (
<DebugConsole data={debugData} />
)}
</>
);
}
/**
* 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 &&
prevProps.debugData === nextProps.debugData
);
});
// 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>
);
}
+521
View File
@@ -0,0 +1,521 @@
'use client';
import { useState, useMemo } from 'react';
import { cn } from '@/lib/utils';
import {
ChevronDown,
ChevronUp,
Copy,
Check,
Brain,
Search,
Settings,
FileText,
Globe,
Clock,
Database,
Code2
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
<<<<<<< HEAD
import { Message, ThinkingMessageMetadata } from '@/hooks/use-chat';
=======
import { Message } from '@/hooks/use-chat';
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
import type { DeepResearchState } from '@/hooks/use-deep-research';
import type { ScrapedContent } from '@/services/webSearch';
export interface DebugData {
model: string;
timestamp: number;
latency?: number;
systemPrompt: string;
userQuery: string;
contextUsed: Message[];
webResearch?: {
queries: string[];
enrichedQueries?: {
literal: string[];
semantic: string[];
related: string[];
expanded: string[];
contextual: string[];
};
sources: ScrapedContent[];
<<<<<<< HEAD
sitesResearched?: Array<{
url: string;
title: string;
status: string;
source: string;
duration?: number;
contentLength: number;
scrapedAt: number;
}>;
=======
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
logs: Array<{
stage: string;
timestamp: number;
input: string;
rawOutput?: string;
parsedOutput?: any;
error?: string;
}>;
plan?: string[];
knowledgeBase?: Array<{
sourceUrl: string;
title: string;
content: string;
}>;
<<<<<<< HEAD
activeQueries?: Array<{
query: string;
source: string;
round?: number;
startedAt: number;
}>;
activeUrls?: Array<{
url: string;
title?: string;
status: string;
source?: string;
startedAt: number;
duration?: number;
}>;
};
deepResearchState?: DeepResearchState;
thinkingSteps?: ThinkingMessageMetadata[];
reasoning?: {
steps?: string[];
intermediateResults?: any[];
};
finalResponse: string;
rawResponse: string;
tokenUsage?: {
input: number;
output: number;
total: number;
=======
};
deepResearchState?: DeepResearchState;
finalResponse: string;
rawResponse?: string;
tokenUsage?: {
input: number;
output: number;
>>>>>>> 593efd42e091a845dea82ee6646e027bce1e18c5
};
}
interface DebugConsoleProps {
data: DebugData;
}
export function DebugConsole({ data }: DebugConsoleProps) {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const [copied, setCopied] = useState(false);
const toggleSection = (section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
};
const copyFullDump = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const formatTimestamp = (ts: number) => {
return new Date(ts).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
const formatDuration = (ms?: number) => {
if (!ms) return 'N/A';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
const isExpanded = (section: string) => expandedSections.has(section);
return (
<div className="mt-6 border-t border-border/50 pt-4">
<div className="bg-muted/30 rounded-lg border border-border/50 overflow-hidden font-mono">
{/* Header Bar */}
<div className="bg-muted/50 border-b border-border/50 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold text-foreground">Debug Console</span>
</div>
<Badge variant="outline" className="text-xs font-mono">
{data.model || 'N/A'}
</Badge>
{data.latency !== undefined && (
<Badge variant="outline" className="text-xs font-mono">
<Clock className="w-3 h-3 mr-1" />
{formatDuration(data.latency)}
</Badge>
)}
{data.tokenUsage && (
<Badge variant="outline" className="text-xs font-mono">
Tokens: {data.tokenUsage.input}/{data.tokenUsage.output}
</Badge>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={copyFullDump}
className="h-7 px-2 text-xs"
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
Copiado
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copiar Dump (JSON)
</>
)}
</Button>
</div>
{/* Sections */}
<div className="divide-y divide-border/50">
{/* Section A: Contexto & Memória */}
<Section
id="context"
title="🧠 Contexto & Memória (Input)"
icon={<Brain className="w-4 h-4" />}
isExpanded={isExpanded('context')}
onToggle={() => toggleSection('context')}
>
<div className="space-y-4 p-4">
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
System Prompt
</h4>
<pre className="text-xs bg-background/50 p-3 rounded border border-border/30 overflow-x-auto whitespace-pre-wrap break-words">
{data.systemPrompt}
</pre>
<p className="text-[10px] text-muted-foreground mt-1">
Injetado em: {formatTimestamp(data.timestamp)}
</p>
</div>
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
Histórico da Sessão ({data.contextUsed.length} mensagens)
</h4>
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground mb-2">
Expandir JSON
</summary>
<pre className="bg-background/50 p-3 rounded border border-border/30 overflow-x-auto">
{JSON.stringify(data.contextUsed, null, 2)}
</pre>
</details>
</div>
</div>
</Section>
{/* Section B: Deep Research */}
{data.webResearch && (
<Section
id="research"
title="🔎 Deep Research (Processamento)"
icon={<Search className="w-4 h-4" />}
isExpanded={isExpanded('research')}
onToggle={() => toggleSection('research')}
>
<div className="space-y-4 p-4">
{/* Pipeline Status */}
<div>
<h4 className="text-xs font-semibold mb-3 text-muted-foreground uppercase tracking-wide">
Status do Pipeline
</h4>
<div className="space-y-3">
{/* Decomposition */}
{data.webResearch.plan && data.webResearch.plan.length > 0 && (
<div className="border-l-2 border-primary/50 pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span className="text-xs font-medium">Decomposition</span>
</div>
<div className="text-xs text-muted-foreground ml-4 space-y-1">
{data.webResearch.plan.map((query, idx) => (
<div key={idx} className="flex items-start gap-2">
<span className="text-muted-foreground/50">{idx + 1}.</span>
<span>{query}</span>
</div>
))}
</div>
</div>
)}
{/* Query Expansion */}
{data.webResearch.enrichedQueries && (
<div className="border-l-2 border-blue-500/50 pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<span className="text-xs font-medium">Query Expansion</span>
</div>
<div className="text-xs text-muted-foreground ml-4 space-y-2">
{data.webResearch.queries.map((original, idx) => {
const eq = data.webResearch?.enrichedQueries;
const allEnriched = eq ? [
...(eq.literal || []),
...(eq.semantic || []),
...(eq.related || []),
...(eq.expanded || []),
...(eq.contextual || []),
] : [];
return (
<div key={idx} className="space-y-1">
<div className="text-muted-foreground/70">
<span className="font-medium">Original:</span> {original}
</div>
{allEnriched.length > 0 && (
<div className="text-muted-foreground space-y-1">
<span className="font-medium">Enriquecidas ({allEnriched.length}):</span>
<div className="ml-2 space-y-0.5">
{allEnriched.slice(0, 5).map((q, qIdx) => (
<div key={qIdx} className="text-[10px]">• {q}</div>
))}
{allEnriched.length > 5 && (
<div className="text-[10px] text-muted-foreground/70">
... e mais {allEnriched.length - 5}
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Web Crawling */}
{data.webResearch.sources && data.webResearch.sources.length > 0 && (
<div className="border-l-2 border-yellow-500/50 pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
<span className="text-xs font-medium">
Web Crawling ({data.webResearch.sources.length} fontes)
</span>
</div>
<div className="text-xs text-muted-foreground ml-4 space-y-2 max-h-60 overflow-y-auto">
{data.webResearch.sources.map((source, idx) => (
<div key={idx} className="flex items-start gap-2 p-2 bg-background/30 rounded border border-border/20">
<div className="flex-shrink-0 mt-0.5">
{source.content && source.content.length > 0 ? (
<span className="text-green-500">✓</span>
) : (
<span className="text-red-500">✗</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{source.title || source.url}</span>
{source.cached && (
<Badge variant="outline" className="text-[10px] px-1">
Cached
</Badge>
)}
</div>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-primary hover:underline truncate block"
>
{source.url}
</a>
{source.content && (
<p className="text-[10px] text-muted-foreground mt-1 line-clamp-2">
{source.content.substring(0, 150)}...
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Knowledge Base */}
{data.webResearch.knowledgeBase && data.webResearch.knowledgeBase.length > 0 && (
<div className="border-l-2 border-purple-500/50 pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 bg-purple-500 rounded-full" />
<span className="text-xs font-medium">Knowledge Base</span>
</div>
<div className="text-xs text-muted-foreground ml-4">
<Badge variant="secondary" className="text-[10px]">
{data.webResearch.knowledgeBase.length} chunks extraídos
</Badge>
</div>
</div>
)}
</div>
</div>
{/* Research Logs */}
{data.webResearch.logs && data.webResearch.logs.length > 0 && (
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
Logs Internos ({data.webResearch.logs.length} entradas)
</h4>
<div className="space-y-2 max-h-60 overflow-y-auto">
{data.webResearch.logs.map((log, idx) => (
<div key={idx} className="text-xs bg-background/30 p-2 rounded border border-border/20">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-[10px] px-1">
{log.stage}
</Badge>
<span className="text-[10px] text-muted-foreground">
{formatTimestamp(log.timestamp)}
</span>
</div>
<div className="text-muted-foreground">
<div className="mb-1">
<span className="font-medium">Input:</span> {log.input}
</div>
{log.rawOutput && (
<div className="mb-1">
<span className="font-medium">Output:</span>{' '}
<span className="text-[10px]">{log.rawOutput.substring(0, 200)}...</span>
</div>
)}
{log.error && (
<div className="text-red-500">
<span className="font-medium">Erro:</span> {log.error}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</Section>
)}
{/* Section C: Dados Processados */}
{data.deepResearchState && (
<Section
id="processed"
title="⚙️ Dados Processados (Raciocínio)"
icon={<Settings className="w-4 h-4" />}
isExpanded={isExpanded('processed')}
onToggle={() => toggleSection('processed')}
>
<div className="space-y-4 p-4">
<div>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground uppercase tracking-wide">
Estado do Deep Research
</h4>
<div className="text-xs space-y-2">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Step:</span>
<Badge variant="outline">{data.deepResearchState.step}</Badge>
</div>
{data.deepResearchState.validationReport && (
<div>
<span className="text-muted-foreground font-medium">Validation Report:</span>
<pre className="mt-1 bg-background/50 p-2 rounded border border-border/30 overflow-x-auto whitespace-pre-wrap break-words text-[10px]">
{data.deepResearchState.validationReport}
</pre>
</div>
)}
{data.deepResearchState.error && (
<div className="text-red-500">
<span className="font-medium">Erro:</span> {data.deepResearchState.error}
</div>
)}
</div>
</div>
</div>
</Section>
)}
{/* Section D: Resposta Raw */}
<Section
id="response"
title="📤 Resposta Raw (Output)"
icon={<FileText className="w-4 h-4" />}
isExpanded={isExpanded('response')}
onToggle={() => toggleSection('response')}
>
<div className="p-4">
<pre className="text-xs bg-background/50 p-3 rounded border border-border/30 overflow-x-auto whitespace-pre-wrap break-words max-h-96 overflow-y-auto">
{data.rawResponse || data.finalResponse}
</pre>
</div>
</Section>
</div>
</div>
</div>
);
}
interface SectionProps {
id: string;
title: string;
icon: React.ReactNode;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function Section({ id, title, icon, isExpanded, onToggle, children }: SectionProps) {
return (
<div>
<button
onClick={onToggle}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-muted/40 transition-colors text-left"
>
<div className="flex items-center gap-2">
{icon}
<span className="text-sm font-medium">{title}</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="bg-background/20">
{children}
</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>
);
}
+158
View File
@@ -0,0 +1,158 @@
'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';
import { SearchQueriesMonitor } from './search-queries-monitor';
import { ScrapingUrlsMonitor } from './scraping-urls-monitor';
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-4 text-sm text-muted-foreground animate-in fade-in">
{metadata.details && (
<div className="whitespace-pre-wrap">{metadata.details}</div>
)}
{/* Monitoramento em Tempo Real - Queries */}
{metadata.activeQueries && metadata.activeQueries.length > 0 && (
<div className="pt-2 border-t border-muted/30">
<SearchQueriesMonitor queries={metadata.activeQueries} />
</div>
)}
{/* Monitoramento em Tempo Real - URLs */}
{metadata.activeUrls && metadata.activeUrls.length > 0 && (
<div className="pt-2 border-t border-muted/30">
<ScrapingUrlsMonitor urls={metadata.activeUrls} />
</div>
)}
{metadata.sources && metadata.sources.length > 0 && (
<div className="pt-2 border-t border-muted/30">
<div className="text-xs font-medium text-muted-foreground mb-2">
Fontes Encontradas ({metadata.sources.length})
</div>
<div className="flex flex-wrap gap-2">
{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>
</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>
);
}
+317
View File
@@ -0,0 +1,317 @@
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown, Search, DownloadCloud, Cpu, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useModelSelection } from '@/hooks/use-model-selection';
import { useDownloadContext } from '@/contexts/download-context';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
export function ModelSelector() {
const [open, setOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const { myModels, availableModels, selectedModel, setSelectedModel, installedVariantsByFamily, refreshModels } = useModelSelection();
const { startDownload, state: downloadState } = useDownloadContext();
const { toast } = useToast();
// Track selected variant for each model in the catalog
const [selectedVariants, setSelectedVariants] = React.useState<Record<string, string>>({});
const handleDownload = async (e: React.MouseEvent, modelName: string) => {
e.stopPropagation();
try {
await startDownload(modelName);
toast({
title: "Download Iniciado",
description: `Baixando ${modelName}...`,
});
} catch (error) {
toast({
title: "Erro",
description: "Falha ao iniciar download.",
variant: "destructive",
});
}
};
const handleSmartDownload = async () => {
const term = searchQuery.trim();
if (!term) return;
try {
await startDownload(term);
setOpen(false);
setSearchQuery("");
toast({
title: "Download Iniciado",
description: `Baixando ${term} da biblioteca Ollama...`,
});
} catch (error) {
toast({
title: "Erro",
description: "Falha ao iniciar download.",
variant: "destructive",
});
}
};
React.useEffect(() => {
if (downloadState?.isSuccess) {
refreshModels();
}
}, [downloadState?.isSuccess, refreshModels]);
const filteredMyModels = React.useMemo(() => {
if (!searchQuery) return myModels;
return myModels.filter(m =>
m.model.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [myModels, searchQuery]);
const filteredAvailableModels = React.useMemo(() => {
if (!searchQuery) return availableModels;
return availableModels.filter(m =>
m.model.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [availableModels, searchQuery]);
// Helper to check if a model is currently selected (partial match for variants)
const isSelected = (modelName: string) => {
if (!selectedModel) return false;
return selectedModel.startsWith(modelName) || selectedModel === modelName;
};
// Helper to extract tag/parameters from model name
const getVariantDisplay = (name: string) => {
if (name.includes(':')) {
return name.split(':')[1];
}
return 'latest';
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[260px] justify-between"
>
{selectedModel ? (
<span className="flex items-center gap-2 truncate">
<Cpu className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="truncate">{selectedModel}</span>
</span>
) : (
"Selecione um modelo..."
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<div className="flex flex-col">
{/* Search Input */}
<div className="flex items-center border-b border-border/50 px-3 bg-muted/30">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
className="flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Buscar modelos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && searchQuery && filteredAvailableModels.length === 0) {
handleSmartDownload();
}
}}
/>
</div>
<Tabs defaultValue="installed" className="w-full">
<TabsList className="w-full rounded-none border-b bg-transparent p-0">
<TabsTrigger
value="installed"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
>
Instalados
</TabsTrigger>
<TabsTrigger
value="available"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent h-10"
>
Catálogo
</TabsTrigger>
</TabsList>
<TabsContent value="installed" className="m-0 max-h-[400px] overflow-y-auto p-2">
{filteredMyModels.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{searchQuery ? (
<span>Nenhum modelo instalado encontrado.</span>
) : (
<span>Nenhum modelo instalado.</span>
)}
</div>
) : (
<div className="flex flex-col gap-2">
{filteredMyModels.map((modelDef) => {
const family = modelDef.model;
const installedTags = installedVariantsByFamily[family] || [];
const isFamilySelected = selectedModel ? (selectedModel === family || selectedModel.startsWith(family + ":")) : false;
return (
<div
key={family}
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
>
<div className="flex items-start justify-between w-full mb-2">
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
<span className="font-bold text-base truncate flex items-center gap-2">
{modelDef.model}
{isFamilySelected && (
<Check className="h-3 w-3 text-primary animate-in fade-in zoom-in" />
)}
</span>
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
{modelDef.description}
</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mt-1">
{installedTags.map((fullTag) => {
const tag = getVariantDisplay(fullTag);
const isExactSelected = selectedModel === fullTag;
// Find variant def for size info if available
const variantDef = modelDef.variants.find(v => v.name === fullTag);
return (
<Badge
key={fullTag}
variant="outline"
className={cn(
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
isExactSelected
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
)}
title={variantDef ? `Tamanho: ${variantDef.size}` : undefined}
onClick={(e) => {
e.stopPropagation();
setSelectedModel(fullTag);
setOpen(false);
}}
>
{tag}
</Badge>
);
})}
</div>
</div>
);
})}
</div>
)}
</TabsContent>
<TabsContent value="available" className="m-0 max-h-[400px] overflow-y-auto p-2">
{filteredAvailableModels.length === 0 ? (
<div className="py-6 text-center text-sm">
{searchQuery ? (
<div className="flex flex-col items-center gap-2">
<p className="text-muted-foreground">Modelo não encontrado no catálogo.</p>
<Button
variant="outline"
className="h-auto py-2 px-4 border-dashed border-zinc-500/50 hover:border-primary hover:bg-primary/10"
onClick={handleSmartDownload}
>
<DownloadCloud className="mr-2 h-4 w-4" />
<span>Baixar <span className="font-bold ml-1">{searchQuery}</span></span>
</Button>
</div>
) : (
<span className="text-muted-foreground">Nenhum modelo encontrado.</span>
)}
</div>
) : (
<div className="flex flex-col gap-2">
{filteredAvailableModels.map((modelDef) => {
// Determine active variant for this item
const activeVariantName = selectedVariants[modelDef.model] || modelDef.variants[0]?.name;
const activeVariant = modelDef.variants.find(v => v.name === activeVariantName) || modelDef.variants[0];
return (
<div
key={modelDef.model}
className="relative flex flex-col rounded-md border border-transparent hover:border-border/50 bg-card/50 hover:bg-accent/30 p-3 text-sm transition-all"
>
<div className="flex items-start justify-between w-full mb-2">
<div className="flex flex-col gap-0.5 overflow-hidden pr-2">
<span className="font-bold text-base truncate">{modelDef.model}</span>
<span className="text-xs text-muted-foreground line-clamp-2" title={modelDef.description}>
{modelDef.description}
</span>
</div>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => handleDownload(e, activeVariant.name)}
disabled={downloadState.isDownloading && downloadState.currentModel === activeVariant.name}
title={`Baixar ${activeVariant.name}`}
>
{downloadState.isDownloading && downloadState.currentModel === activeVariant.name ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<DownloadCloud className="h-4 w-4" />
)}
</Button>
</div>
{/* Variants Pills */}
<div className="flex flex-wrap gap-1.5 mt-1">
{modelDef.variants.map((variant) => {
const isVariantSelected = activeVariant.name === variant.name;
return (
<Badge
key={variant.name}
variant="outline"
className={cn(
"cursor-pointer text-[10px] px-2 h-5 transition-all border-transparent",
isVariantSelected
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground"
)}
title={`Tamanho em disco: ${variant.size}`}
onClick={(e) => {
e.stopPropagation();
setSelectedVariants(prev => ({
...prev,
[modelDef.model]: variant.name
}));
}}
>
{getVariantDisplay(variant.name)}
</Badge>
);
})}
</div>
</div>
)})}
</div>
)}
</TabsContent>
</Tabs>
</div>
</PopoverContent>
</Popover>
);
}
+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>
);
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import { Loader2, CheckCircle2, XCircle, ExternalLink, Globe } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { ActiveUrl } from '@/hooks/use-web-search-events';
interface ScrapingUrlsMonitorProps {
urls: ActiveUrl[];
className?: string;
}
export function ScrapingUrlsMonitor({ urls, className }: ScrapingUrlsMonitorProps) {
if (urls.length === 0) return null;
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 getStatusIcon = (status: ActiveUrl['status']) => {
switch (status) {
case 'started':
return <Loader2 className="w-3 h-3 animate-spin text-primary" />;
case 'completed':
case 'cached':
return <CheckCircle2 className="w-3 h-3 text-green-500" />;
case 'failed':
return <XCircle className="w-3 h-3 text-red-500" />;
default:
return <Globe className="w-3 h-3 text-muted-foreground" />;
}
};
const getSourceBadge = (source?: string) => {
if (!source) return null;
const colors: Record<string, string> = {
static: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
nodriver: 'bg-purple-500/10 text-purple-600 border-purple-500/20',
playwright: 'bg-orange-500/10 text-orange-600 border-orange-500/20',
cached: 'bg-gray-500/10 text-gray-600 border-gray-500/20',
};
return (
<Badge variant="outline" className={cn('text-xs', colors[source] || 'bg-muted')}>
{source}
</Badge>
);
};
return (
<div className={cn('space-y-2', className)}>
<div className="text-xs font-medium text-muted-foreground mb-2">
Sites Sendo Acessados ({urls.length})
</div>
{urls.map((url, idx) => (
<div
key={`${url.url}-${idx}`}
className="flex items-start gap-2 p-2 rounded-md border bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(url.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<img
src={getFaviconUrl(url.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">
<div className="flex items-center gap-2 flex-wrap">
<a
href={url.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-medium text-primary hover:underline line-clamp-1 flex items-center gap-1"
>
{url.title || url.url}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
{getSourceBadge(url.source)}
{url.duration && (
<span className="text-xs text-muted-foreground">
{url.duration}ms
</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{url.url}
</p>
</div>
</div>
</div>
</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>
);
}
@@ -0,0 +1,70 @@
'use client';
import { Loader2, CheckCircle2, Globe, Search } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { ActiveQuery } from '@/hooks/use-web-search-events';
interface SearchQueriesMonitorProps {
queries: ActiveQuery[];
className?: string;
}
export function SearchQueriesMonitor({ queries, className }: SearchQueriesMonitorProps) {
if (queries.length === 0) return null;
const getSourceColor = (source: string) => {
switch (source) {
case 'brave':
return 'bg-blue-500/10 text-blue-600 border-blue-500/20';
case 'searxng':
return 'bg-green-500/10 text-green-600 border-green-500/20';
case 'duckduckgo':
return 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20';
default:
return 'bg-muted text-muted-foreground border-muted';
}
};
const getSourceLabel = (source: string) => {
switch (source) {
case 'brave':
return 'Brave';
case 'searxng':
return 'SearXNG';
case 'duckduckgo':
return 'DuckDuckGo';
default:
return source;
}
};
return (
<div className={cn('space-y-2', className)}>
<div className="text-xs font-medium text-muted-foreground mb-2">
Consultas Ativas ({queries.length})
</div>
{queries.map((query, idx) => (
<div
key={`${query.query}-${query.source}-${idx}`}
className="flex items-center gap-2 p-2 rounded-md border bg-muted/30 hover:bg-muted/50 transition-colors"
>
<Loader2 className="w-3 h-3 animate-spin text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs truncate">{query.query}</span>
<Badge variant="outline" className={cn('text-xs', getSourceColor(query.source))}>
{getSourceLabel(query.source)}
</Badge>
{query.round && (
<Badge variant="outline" className="text-xs">
Round {query.round}
</Badge>
)}
</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>
</>
);
}
+80
View File
@@ -0,0 +1,80 @@
'use client';
import { useDownloadContext } from '@/contexts/download-context';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
export function GlobalDownloadStatusBar() {
const { state, cancelDownload } = useDownloadContext();
if (!state.isDownloading) {
return null;
}
const { progress, currentModel, isCancelling } = state;
const modelDisplayName = currentModel || 'Modelo';
const percent = progress.percent ?? 0;
const hasSizeInfo = progress.totalBytes > 0 && progress.downloadedBytes > 0;
return (
<div
className={cn(
'fixed bottom-0 left-0 right-0 z-50',
'bg-background/95 backdrop-blur-sm border-t border-border',
'shadow-lg',
'animate-in slide-in-from-bottom duration-300',
'transition-all'
)}
>
<div className="container mx-auto px-4 py-3">
<div className="flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-medium text-foreground truncate">
{isCancelling ? (
'Cancelando download...'
) : (
`Baixando Modelo: ${modelDisplayName} (${percent}%)`
)}
</span>
{progress.speed && !isCancelling && (
<span className="text-xs text-muted-foreground font-mono ml-2 shrink-0">
{progress.speed}
</span>
)}
</div>
<Progress
value={percent}
className="h-1.5 mb-1.5"
/>
{hasSizeInfo && (
<div className="flex justify-between items-center text-[10px] text-muted-foreground/70 uppercase font-medium tracking-wider">
<span>
{progress.downloadedFormatted} / {progress.totalFormatted}
</span>
{progress.status && (
<span className="normal-case">{progress.status}</span>
)}
</div>
)}
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={cancelDownload}
disabled={isCancelling}
className="shrink-0 h-8 w-8"
aria-label="Cancelar download"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
export function KeyboardShortcuts() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// CTRL+SHIFT+D para abrir devtools
if (e.ctrlKey && e.shiftKey && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
e.stopPropagation();
invoke('toggle_devtools').catch(console.error);
return;
}
// Bloquear F5 (refresh)
if (e.key === 'F5') {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+R (refresh)
if (e.ctrlKey && (e.key === 'r' || e.key === 'R') && !e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+R (hard refresh)
if (e.ctrlKey && e.shiftKey && (e.key === 'r' || e.key === 'R')) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+I (devtools)
if (e.ctrlKey && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+J (console)
if (e.ctrlKey && e.shiftKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+U (view source)
if (e.ctrlKey && (e.key === 'u' || e.key === 'U') && !e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+DEL (clear storage)
if (e.ctrlKey && e.shiftKey && e.key === 'Delete') {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+W (fechar aba - mas permitir fechar janela com o comportamento customizado do Tauri)
// Não bloqueamos CTRL+W porque o Tauri já trata isso no on_window_event
// Bloquear CTRL+N (nova janela)
if (e.ctrlKey && (e.key === 'n' || e.key === 'N') && !e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+SHIFT+N (nova janela anônima)
if (e.ctrlKey && e.shiftKey && (e.key === 'n' || e.key === 'N')) {
e.preventDefault();
e.stopPropagation();
return;
}
// Bloquear CTRL+T (nova aba)
if (e.ctrlKey && (e.key === 't' || e.key === 'T') && !e.shiftKey && !e.altKey) {
e.preventDefault();
e.stopPropagation();
return;
}
};
// Adicionar listener com capture para interceptar antes de outros handlers
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
};
}, []);
return null;
}
+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>
)
}
-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>
);
}
-320
View File
@@ -1,320 +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-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Install MCP Manually
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Description */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
How to use
</h3>
<p className="text-sm text-blue-700 dark:text-blue-300 mb-3">
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-blue-600 dark:text-blue-400 hover:underline"
>
Click here to load an example
</button>
</div>
{/* MCP ID (optional) */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
MCP ID (optional)
</label>
<input
type="text"
value={mcpId}
onChange={(e) => setMcpId(e.target.value)}
placeholder="my-custom-mcp (auto-detected if empty)"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Configuration JSON */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Configuration JSON *
</label>
<textarea
value={configJson}
onChange={(e) => handleConfigChange(e.target.value)}
placeholder={`Paste your configuration here, e.g.:
{
"mcpServers": {
"time-server": {
"command": "npx",
"args": ["@mcpcentral/mcp-time"]
}
}
}`}
rows={15}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm focus:ring-2 focus:ring-blue-500 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 dark:text-red-400">
{validationError}
</span>
</>
) : parsedConfig ? (
<>
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-sm text-green-600 dark:text-green-400">
Valid configuration
</span>
</>
) : null}
</div>
)}
</div>
{/* Preview */}
{parsedConfig && !validationError && (
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Preview
</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="text-gray-700 dark:text-gray-300">
<span className="font-medium">Server:</span> {name}
</div>
<div className="text-gray-600 dark:text-gray-400">
<span className="font-medium">Command:</span>{" "}
{config.command} {(config.args || []).join(" ")}
</div>
{config.env && (
<div className="text-gray-600 dark:text-gray-400">
<span className="font-medium">Environment:</span>{" "}
{Object.keys(config.env).length} variables
</div>
)}
</div>
)
)
) : (
<div className="space-y-1">
<div className="text-gray-600 dark:text-gray-400">
<span className="font-medium">Command:</span>{" "}
{parsedConfig.command}{" "}
{(parsedConfig.args || []).join(" ")}
</div>
{parsedConfig.env && (
<div className="text-gray-600 dark:text-gray-400">
<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 dark:text-red-100">
Installation Error
</h4>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error}
</p>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="sticky bottom-0 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 p-4 flex gap-3 justify-end">
<button
onClick={onClose}
disabled={isInstalling}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleInstall}
disabled={isInstalling || !parsedConfig || !!validationError}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<Upload className="h-4 w-4" />
Install MCP
</>
)}
</button>
</div>
</div>
</div>
);
}
-97
View File
@@ -1,97 +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) {
const text = String(children || "");
if (inline) {
return (
<code className="rounded bg-[var(--surface)] px-1 py-0.5 text-[0.95em]">
{children}
</code>
);
}
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(text)}
aria-label="Copiar código"
>
<Copy className="h-3 w-3" />
</button>
<pre className="overflow-auto p-3">
<code className={className} {...props}>
{children}
</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>
);
}
-105
View File
@@ -1,105 +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">
<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
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
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
onClick={() => handleModeSelect("markdown")}
>
Markdown
</button>
<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
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
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>
);
}
+340
View File
@@ -0,0 +1,340 @@
'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 será mostrado automaticamente via eventos de progresso
} 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;
const isInstalling = downloadState.isInstalling && isActive;
const installStatus = downloadState.installStatus;
if (!isDownloading && !hasDownloaded && !isInstalling) {
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" />
</>
)}
{isInstalling && (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{installStatus || 'Instalando...'}
</span>
<Loader2 className="h-4 w-4 animate-spin text-primary" />
</div>
<Progress value={undefined} className="h-2" />
</>
)}
{hasDownloaded && !isDownloading && !isInstalling && (
<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 automática.
</p>
</div>
</div>
)}
{downloadState.isInstalled && !isInstalling && (
<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">
Instalação concluída!
</p>
<p className="text-green-700 dark:text-green-300">
{installStatus || 'Ollama foi instalado com sucesso.'}
</p>
</div>
</div>
)}
</div>
);
};
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle data-tauri-drag-region className="cursor-move select-none">
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>
);
}
-465
View File
@@ -1,465 +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>
</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>
);
}
-145
View File
@@ -1,145 +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);
useEffect(() => {
if (!isOpen) {
setIsPulling(false);
setProgress(null);
setError(null);
}
}, [isOpen]);
const handleConfirm = async () => {
setIsPulling(true);
setError(null);
try {
await onConfirmPull(modelName);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to pull model");
} finally {
setIsPulling(false);
}
};
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">
<p className="text-sm text-[var(--foreground)]/80 mb-2">
Install <span className="font-medium">{modelName}</span>?
</p>
<p className="text-xs text-[var(--foreground)]/60">
This will download the model to your local Ollama installation.
</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>Downloading...</span>
<span>
{progress.total && progress.completed
? `${Math.round(
(progress.completed / progress.total) * 100,
)}%`
: "0%"}
</span>
</div>
<div className="w-full bg-[var(--border)] rounded-full h-2">
<div
className="bg-[var(--accent)] h-2 rounded-full transition-all duration-300"
style={{
width:
progress.total && progress.completed
? `${(progress.completed / progress.total) * 100}%`
: "0%",
}}
/>
</div>
{progress.digest && (
<p className="text-xs text-[var(--foreground)]/60 mt-1">
{progress.digest.slice(0, 12)}...
</p>
)}
</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"
>
Cancel
</button>
<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,77 @@
'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>
);
}
+279
View File
@@ -0,0 +1,279 @@
'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>
);
}
+222
View File
@@ -0,0 +1,222 @@
'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 (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Skeleton Cards */}
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="h-4 w-16 bg-muted animate-pulse rounded" />
<div className="h-5 w-12 bg-muted animate-pulse rounded" />
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="h-2 w-full bg-muted animate-pulse rounded" />
<div className="h-3 w-24 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>Carregando estatísticas do sistema...</span>
</div>
</CardContent>
</Card>
</div>
);
}
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>
);
}
Loaded 100 of 514 files, more files were not shown because too many files have changed in this diff. Show more