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.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-28 15:15:38 -03:00
1 parent 55749c07e3
commit c7da6cb8a8
36 files changed
+1375 -210

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
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
+342 -20
View File
@@ -1,36 +1,358 @@
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). # OllaHub
Local-first LLM workspace with deep research capabilities and native OS integration.
[![Rust](https://img.shields.io/badge/Rust-1.77.2-orange.svg)](https://www.rust-lang.org/)
[![Tauri](https://img.shields.io/badge/Tauri-2.9.2-24C8DB.svg)](https://tauri.app/)
[![Next.js](https://img.shields.io/badge/Next.js-16.0.3-black.svg)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue.svg)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-Custom-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/Version-0.1.0-blue.svg)](https://github.com/evandrodevbr/OllaHub)
## Overview
OllaHub is a desktop application that orchestrates local Large Language Models (LLMs) through Ollama, providing a private, offline-capable workspace for research, analysis, and conversation. The application executes complex research workflows by decomposing queries, performing parallel web scraping, and synthesizing context from multiple sources before generating responses.
The architecture prioritizes data privacy by keeping all processing local, using SQLite with full-text search for efficient storage, and integrating ONNX Runtime for semantic relevance ranking without external API dependencies.
## Key Features
### Deep Research Pipeline
The application implements a multi-stage research workflow:
1. **Query Decomposition**: Uses LLM to break complex queries into 3-5 atomic, search-optimized sub-queries
2. **Parallel Web Scraping**: Executes multiple searches concurrently using headless Chrome and DuckDuckGo metadata API
3. **Knowledge Base Aggregation**: Collects and deduplicates content from multiple sources
4. **Context Condensation**: Applies relevance scoring and summarization to fit context within token limits
5. **Validation**: Verifies knowledge base sufficiency before response generation
The pipeline includes safety checks to ensure decomposed queries remain relevant to the original intent, with automatic fallback to the original query if decomposition fails.
### Local-First Architecture
- **Ollama Integration**: Direct subprocess management of Ollama for model execution
- **ONNX Embeddings**: Local semantic similarity using all-MiniLM-L6-v2 (384-dimensional embeddings)
- **SQLite with FTS5**: Full-text search with virtual tables for session titles and message content
- **Hybrid Storage**: Dual persistence (SQLite + JSON) with automatic migration and fallback
### Hybrid Storage System
The application uses SQLite as the primary storage backend with JSON as a fallback:
- **SQLite (Primary)**: WAL mode for concurrent reads/writes, optimized cache settings, FTS5 virtual tables for search
- **JSON (Legacy)**: Maintained for backward compatibility and export functionality
- **Automatic Migration**: Seamless transition from JSON to SQLite with data preservation
### Reactive UI
Built with React 19 and Next.js 16, the interface uses:
- **Tailwind CSS**: Utility-first styling with design tokens
- **Radix UI / Shadcn**: Accessible, headless component primitives
- **Zustand**: Lightweight state management for settings and UI state
- **Framer Motion**: Smooth animations for state transitions
- **Resizable Panels**: Customizable layout with persistent preferences
### MCP (Model Context Protocol) Integration
Supports external tool integration through MCP servers:
- **JSON-RPC Communication**: Bidirectional communication with MCP servers via stdin/stdout
- **Tool Discovery**: Automatic enumeration of available tools from running servers
- **Process Management**: Lifecycle management (start, stop, restart) with health monitoring
- **Tool Execution**: Runtime invocation of MCP tools with result injection into LLM context
### System Monitoring
Real-time hardware monitoring and resource management:
- **GPU Detection**: NVIDIA and AMD GPU identification with utilization tracking
- **CPU/Memory Stats**: System-wide resource usage via `sysinfo` crate
- **Model Recommendations**: Hardware-aware model suggestions based on available VRAM/RAM
### Task Scheduler
Cron-based task execution system:
- **Recurring Tasks**: Schedule periodic operations (e.g., knowledge base updates)
- **Task Queue**: Priority-based execution with retry logic
- **State Persistence**: Task state survives application restarts
## Architecture
```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
```
### Data Flow
1. **User Query** → Frontend captures input and triggers preprocessing
2. **Query Preprocessing** → Intent classification, entity extraction, query enrichment
3. **Deep Research** → Query decomposition → Parallel search → Scraping → Knowledge base aggregation
4. **Context Condensation** → Relevance scoring (ONNX embeddings) → Summarization → Token optimization
5. **LLM Generation** → Ollama subprocess with streaming response
6. **Storage** → SQLite persistence with FTS5 indexing for future retrieval
## Tech Stack
### Core
| Layer | Technology | Version | Purpose |
|-------|-----------|---------|---------|
| **Desktop Framework** | Tauri | 2.9.2 | Native desktop app with Rust backend |
| **Backend Language** | Rust | 1.77.2 | System-level operations, performance-critical paths |
| **Frontend Framework** | Next.js | 16.0.3 | React-based UI with SSR capabilities |
| **UI Library** | React | 19.2.0 | Component-based interface |
| **Language** | TypeScript | 5.0 | Type-safe frontend development |
### Frontend
| Category | Technology | Purpose |
|----------|-----------|---------|
| **State Management** | Zustand | 5.0.8 | Settings and UI state |
| **Styling** | Tailwind CSS | 4.0 | Utility-first CSS framework |
| **Components** | Radix UI | Latest | Accessible primitives (dialogs, dropdowns, tooltips) |
| **Components** | Shadcn/ui | Custom | Pre-built component library |
| **Animations** | Framer Motion | 12.23.24 | Smooth transitions |
| **Markdown** | react-markdown | 10.1.0 | Message rendering with GFM support |
| **Syntax Highlighting** | react-syntax-highlighter | 16.1.0 | Code block rendering |
### Backend
| Category | Technology | Purpose |
|----------|-----------|---------|
| **Database** | SQLite (rusqlite) | 0.31 | Local data persistence with FTS5 |
| **Web Scraping** | headless_chrome | 1.0 | Browser automation for content extraction |
| **HTML Parsing** | scraper | 0.18 | DOM parsing and content extraction |
| **Text Processing** | readability | 0.3 | Main content extraction from HTML |
| **HTTP Client** | reqwest | 0.12 | API requests with streaming support |
| **Async Runtime** | tokio | 1.0 | Async I/O and concurrency |
| **Task Scheduling** | tokio-cron-scheduler | 0.9 | Cron-based task execution |
| **System Info** | sysinfo | 0.36.1 | Hardware monitoring |
| **Embeddings** | ONNX Runtime (ort) | 2.0.0-rc.10 | Local semantic similarity |
| **Tokenization** | tokenizers | 0.21 | Text tokenization for embeddings |
| **Array Operations** | ndarray | 0.16 | Numerical computations |
### AI/ML
| Component | Technology | Purpose |
|-----------|-----------|---------|
| **LLM Runtime** | Ollama | Local model execution |
| **Embeddings Model** | all-MiniLM-L6-v2 | 384-dim semantic embeddings |
| **Embeddings Runtime** | ONNX Runtime | Model inference |
## Getting Started ## Getting Started
First, run the development server: ### Prerequisites
- **Node.js**: 20.x or later
- **Rust**: 1.77.2 or later
- **pnpm**: Latest version (`npm install -g pnpm`)
- **Ollama**: [Install Ollama](https://ollama.ai) and ensure it's running on `http://localhost:11434`
### Installation
1. Clone the repository:
```bash ```bash
npm run dev git clone https://github.com/evandrodevbr/OllaHub.git
# or cd OllaHub
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 2. Install dependencies:
```bash
pnpm install
```
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 3. Run in development mode:
```bash
pnpm tauri dev
```
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. This command will:
- Start the Next.js development server on `http://localhost:3000`
- Build the Rust backend
- Launch the Tauri application window
## Learn More ### Building for Production
To learn more about Next.js, take a look at the following resources: ```bash
pnpm tauri build
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. The built application will be in `src-tauri/target/release/bundle/`:
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - **Windows**: `.msi` installer
- **macOS**: `.dmg` or `.app`
- **Linux**: `.deb`, `.AppImage`, or `.rpm`
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ### Environment Configuration
## Deploy on Vercel The application uses Tauri's built-in configuration. No environment variables are required for basic operation. MCP server configurations are stored in the application data directory and managed through the UI.
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. ## Project Structure
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ```
OllaHub/
├── app/ # Next.js app directory (pages)
│ ├── chat/ # Chat interface
│ ├── settings/ # Application settings
│ ├── setup/ # Initial setup wizard
│ └── tasks/ # Task management
├── components/ # React components
│ ├── chat/ # Chat-specific components
│ ├── settings/ # Settings UI components
│ ├── setup/ # Setup wizard components
│ └── ui/ # Reusable UI primitives (Shadcn)
├── hooks/ # React custom hooks
│ ├── use-chat.ts # Chat state management
│ ├── use-deep-research.ts # Research pipeline orchestration
│ ├── use-mcp-servers.ts # MCP server management
│ └── ...
├── lib/ # Utility functions
│ ├── knowledge-base-processor.ts # Context condensation
│ ├── contextual-analyzer.ts # Query analysis
│ ├── query-enricher.ts # Query expansion
│ └── ...
├── services/ # Service layer
│ └── webSearch.ts # Web search abstraction
├── store/ # Zustand stores
│ └── settings-store.ts # Application settings
├── src-tauri/ # Rust backend
│ ├── src/
│ │ ├── lib.rs # Main Tauri commands
│ │ ├── db.rs # SQLite database layer
│ │ ├── embeddings.rs # ONNX embeddings module
│ │ ├── web_scraper.rs # Web scraping logic
│ │ ├── scheduler.rs # Task scheduling
│ │ └── ...
│ └── Cargo.toml # Rust dependencies
└── data/ # Static data
├── models/ # Model metadata JSON files
└── prompts/ # LLM prompt templates
```
## Design Patterns
### Backend (Rust)
- **Command Pattern**: Tauri commands encapsulate backend operations
- **State Management**: Arc<Mutex<>> for shared state (browser instances, MCP processes)
- **Error Handling**: Result<T, E> with custom error types and fallback strategies
- **Async/Await**: Tokio runtime for concurrent operations (scraping, MCP communication)
### Frontend (TypeScript)
- **Custom Hooks**: Encapsulate complex logic (deep research, chat management)
- **State Machines**: Step-based workflows (idle → planning → searching → aggregating)
- **Optimistic Updates**: UI updates before backend confirmation
- **Streaming**: Real-time token rendering via Tauri events
### Data Layer
- **Repository Pattern**: Database abstraction in `db.rs`
- **Migration Strategy**: Dual storage (SQLite + JSON) with automatic fallback
- **Full-Text Search**: FTS5 virtual tables with trigger-based synchronization
## Roadmap
### Planned Features
- **Vector Store Integration**: Replace SQLite BLOB embeddings with dedicated vector database (LanceDB or Qdrant)
- **Cloud Sync**: Optional encrypted synchronization of chat sessions across devices
- **Plugin System**: Extensible architecture for custom research workflows
- **Advanced RAG**: Multi-modal retrieval with image and document support
- **Model Fine-tuning UI**: Interface for training custom models on user data
- **Collaborative Features**: Shared workspaces and team collaboration
### Technical Improvements
- **Performance**: Optimize SQLite queries with prepared statements caching
- **Memory Management**: Implement LRU cache for embeddings model
- **Error Recovery**: Enhanced retry logic with exponential backoff
- **Testing**: Comprehensive unit and integration test coverage
- **Documentation**: API documentation with code examples
## Contributing
Contributions are welcome. Please follow these guidelines:
1. **Code Style**: Follow existing patterns (Rust: `rustfmt`, TypeScript: ESLint)
2. **Commits**: Use conventional commits (feat:, fix:, docs:, etc.)
3. **Pull Requests**: Include description of changes and test results
4. **Issues**: Use GitHub Issues for bug reports and feature requests
## License
OllaHub is released under a custom license that combines freeware, creditware, and non-commercial restrictions. See the [LICENSE](LICENSE) file for full terms.
### Quick Summary
**You may:**
- Use OllaHub freely for personal, educational, and internal professional use
- Modify and distribute the Software (with proper attribution)
- Fork the project and create derivative works (with attribution)
**You must:**
- Give credit to **Evandro Fonseca Junior** and link to [evandro.dev.br](https://evandro.dev.br)
- Keep copyright notices and attribution intact
- Not sell the Software or charge for access without written permission
**You may not:**
- Sell the Software or bundle it in paid products (without permission)
- Use it as a monetization mechanism
- Remove attribution or copyright notices
- Collect donations on behalf of OllaHub
For commercial licensing inquiries, visit [evandro.dev.br](https://evandro.dev.br).
## Acknowledgments
- [Ollama](https://ollama.ai) for local LLM execution
- [Tauri](https://tauri.app) for desktop framework
- [Radix UI](https://www.radix-ui.com) for accessible components
- [Shadcn](https://ui.shadcn.com) for component library
- [ONNX Runtime](https://onnxruntime.ai) for embeddings inference
+44 -122
View File
@@ -3,7 +3,7 @@
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, ScrollText, Copy, Check, Globe, RefreshCw, Cpu } from "lucide-react"; import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown, Plus, RefreshCw, Cpu } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ChatInput } from "@/components/chat/chat-input"; import { ChatInput } from "@/components/chat/chat-input";
import { ChatMessage } from "@/components/chat/chat-message"; import { ChatMessage } from "@/components/chat/chat-message";
@@ -33,6 +33,7 @@ import { chatLog } from "@/lib/terminal-logger";
import { useQueryPreprocessor, type PreprocessedQuery } from "@/hooks/use-query-preprocessor"; import { useQueryPreprocessor, type PreprocessedQuery } from "@/hooks/use-query-preprocessor";
import type { Message, ThinkingMessageMetadata, ThinkingStepType, ThinkingStepStatus } from "@/hooks/use-chat"; import type { Message, ThinkingMessageMetadata, ThinkingStepType, ThinkingStepStatus } from "@/hooks/use-chat";
import { executeProgressiveSearch } from "@/lib/web-search-fallback"; import { executeProgressiveSearch } from "@/lib/web-search-fallback";
import type { DebugData } from "@/components/chat/debug-console";
// @ts-expect-error - MD file import // @ts-expect-error - MD file import
import defaultFormatPrompt from "@/data/prompts/default-format.md"; import defaultFormatPrompt from "@/data/prompts/default-format.md";
@@ -125,14 +126,9 @@ export default function ChatPage() {
const lastSavedMessagesRef = useRef<number>(0); const lastSavedMessagesRef = useRef<number>(0);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showContextDebug, setShowContextDebug] = useState(false);
const [showProcessDebug, setShowProcessDebug] = useState(false);
const [lastWebContext, setLastWebContext] = useState('');
const [lastContextSources, setLastContextSources] = useState<ScrapedContent[]>([]);
const [showDownloadDialog, setShowDownloadDialog] = useState(false); const [showDownloadDialog, setShowDownloadDialog] = useState(false);
const [logsCopied, setLogsCopied] = useState(false);
const [lastUserQuery, setLastUserQuery] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(0); const [searchMatchIndex, setSearchMatchIndex] = useState(0);
const [debugDataMap, setDebugDataMap] = useState<Map<number, DebugData>>(new Map());
// Resetar índice quando busca é limpa ou sessão muda // Resetar índice quando busca é limpa ou sessão muda
useEffect(() => { useEffect(() => {
@@ -369,13 +365,6 @@ export default function ChatPage() {
chatLog.info(`Should Search: ${preprocessed.shouldSearch}`); chatLog.info(`Should Search: ${preprocessed.shouldSearch}`);
chatLog.info(`Timestamp: ${new Date().toISOString()}`); chatLog.info(`Timestamp: ${new Date().toISOString()}`);
setShowContextDebug(false);
setShowProcessDebug(false);
setLastWebContext('');
setLastContextSources([]);
setLastUserQuery(content); // Store for copy logs
setLogsCopied(false);
// Generate ID if new session (but don't create card yet - wait for title) // Generate ID if new session (but don't create card yet - wait for title)
if (!currentSessionId) { if (!currentSessionId) {
const newId = crypto.randomUUID(); const newId = crypto.randomUUID();
@@ -598,8 +587,6 @@ export default function ChatPage() {
chatLog.warn('⚠️ Knowledge Base Context is empty!'); chatLog.warn('⚠️ Knowledge Base Context is empty!');
} }
setLastWebContext(knowledgeBaseContext);
setLastContextSources(scrapedSources);
// Atualizar mensagem: Processamento concluído // Atualizar mensagem: Processamento concluído
updateThinkingMessage('processing', { updateThinkingMessage('processing', {
@@ -627,8 +614,6 @@ export default function ChatPage() {
deepResearch.addToKnowledgeBase(simpleQuery, results); deepResearch.addToKnowledgeBase(simpleQuery, results);
knowledgeBaseContext = deepResearch.getCuratedContext(); knowledgeBaseContext = deepResearch.getCuratedContext();
scrapedSources = results; scrapedSources = results;
setLastWebContext(knowledgeBaseContext);
setLastContextSources(scrapedSources);
} }
} else { } else {
chatLog.info('Simple query returned NO_SEARCH, skipping web search'); chatLog.info('Simple query returned NO_SEARCH, skipping web search');
@@ -837,6 +822,46 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
chatLog.info('✅ Response generation complete'); chatLog.info('✅ Response generation complete');
chatLog.info('========== QUERY COMPLETE ==========\n'); chatLog.info('========== QUERY COMPLETE ==========\n');
// Construir dados de debug para a última mensagem do assistente
setMessages(prev => {
const newMessages = [...prev];
const lastMsgIndex = newMessages.length - 1;
if (lastMsgIndex >= 0 && newMessages[lastMsgIndex].role === 'assistant') {
const lastMsg = newMessages[lastMsgIndex];
const debugData: DebugData = {
model: selectedModel,
timestamp: Date.now(),
latency: Date.now() - responseStart,
systemPrompt: enhancedSystemPrompt,
userQuery: content,
contextUsed: messages.filter(m => m.role !== 'system'),
webResearch: {
queries: deepResearch.state.plan || [],
enrichedQueries: deepResearch.state.enrichedQueries || undefined,
sources: scrapedSources,
logs: deepResearch.state.logs,
plan: deepResearch.state.plan,
knowledgeBase: deepResearch.state.knowledgeBase.map(entry => ({
sourceUrl: entry.sourceUrl,
title: entry.title,
content: entry.content,
})),
},
deepResearchState: deepResearch.state,
finalResponse: lastMsg.content,
rawResponse: lastMsg.content,
};
// Armazenar debug data usando o índice da mensagem
setDebugDataMap(prevMap => {
const newMap = new Map(prevMap);
newMap.set(lastMsgIndex, debugData);
return newMap;
});
}
return newMessages;
});
// Persistir fontes na mensagem do assistente (metadata) // Persistir fontes na mensagem do assistente (metadata)
if (scrapedSources.length > 0) { if (scrapedSources.length > 0) {
// Aguardar um pouco para garantir que o sendMessage iniciou e criou a mensagem do assistente // Aguardar um pouco para garantir que o sendMessage iniciou e criou a mensagem do assistente
@@ -998,45 +1023,6 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
} }
}; };
// Copy Logs to Clipboard
const handleCopyLogs = async () => {
const logs = deepResearch.state.logs;
const lastAssistantMessage = messages.filter(m => m.role === 'assistant').pop();
let logText = `# Deep Research Debug Log\n\n`;
logText += `## User Query\n${lastUserQuery}\n\n`;
logText += `## Model\n${selectedModel}\n\n`;
logText += `## Process Logs\n\n`;
logs.forEach((log, idx) => {
logText += `### ${idx + 1}. Stage: ${log.stage.toUpperCase()}\n`;
logText += `- **Time**: ${new Date(log.timestamp).toLocaleTimeString()}\n`;
logText += `- **Input**: ${log.input.substring(0, 500)}${log.input.length > 500 ? '...' : ''}\n`;
if (log.rawOutput) {
logText += `- **Raw Output**: ${log.rawOutput.substring(0, 500)}${log.rawOutput.length > 500 ? '...' : ''}\n`;
}
if (log.parsedOutput) {
logText += `- **Parsed Output**: \`\`\`json\n${JSON.stringify(log.parsedOutput, null, 2)}\n\`\`\`\n`;
}
if (log.error) {
logText += `- **Error**: ${log.error}\n`;
}
logText += '\n';
});
logText += `## AI Response\n`;
logText += lastAssistantMessage?.content || '[No response yet]';
logText += '\n\n---\n';
logText += `Generated at: ${new Date().toISOString()}`;
try {
await navigator.clipboard.writeText(logText);
setLogsCopied(true);
setTimeout(() => setLogsCopied(false), 2000);
} catch (err) {
console.error('Failed to copy logs:', err);
}
};
return ( return (
<div className="h-screen w-full bg-background overflow-hidden flex flex-col overflow-x-hidden"> <div className="h-screen w-full bg-background overflow-hidden flex flex-col overflow-x-hidden">
@@ -1380,6 +1366,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
highlightTerm={searchQuery && searchQuery.trim().length >= 2 ? searchQuery.trim() : undefined} highlightTerm={searchQuery && searchQuery.trim().length >= 2 ? searchQuery.trim() : undefined}
highlightIndex={messageMatchIndex} highlightIndex={messageMatchIndex}
messageIndex={group.index} messageIndex={group.index}
debugData={group.index !== undefined ? debugDataMap.get(group.index) : undefined}
/> />
</div> </div>
); );
@@ -1390,71 +1377,6 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div> </div>
)} )}
{/* Debug Info (Moved inside scroll) */}
{(deepResearch.state.logs.length > 0 || lastWebContext) && (
<div className="mt-12 pt-6 border-t border-dashed border-muted/50 opacity-70 hover:opacity-100 transition-opacity">
<div className="space-y-4">
{/* Process Log Toggle */}
{deepResearch.state.logs.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowProcessDebug((prev) => !prev)}
className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
<ScrollText className="w-3 h-3" />
Debug: Processo de Pensamento
<ChevronDown className={`w-3 h-3 transition-transform ${showProcessDebug ? 'rotate-180' : ''}`} />
</button>
{showProcessDebug && (
<div className="mt-2 space-y-2 p-4 rounded-lg bg-muted/30 text-xs border border-muted/50 max-h-60 overflow-y-auto">
<div className="flex justify-end mb-2">
<Button variant="ghost" size="sm" onClick={handleCopyLogs} className="h-6 px-2 text-[10px]">
{logsCopied ? <Check className="w-3 h-3 mr-1" /> : <Copy className="w-3 h-3 mr-1" />} Copiar Logs
</Button>
</div>
{deepResearch.state.logs.map((log, idx) => (
<div key={idx} className="grid grid-cols-[60px_1fr] gap-2 border-b border-muted/20 pb-2 last:border-0">
<span className="text-muted-foreground uppercase text-[10px]">{log.stage}</span>
<span>{log.input ? (log.input.length > 50 ? log.input.substring(0, 50) + '...' : log.input) : '-'}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Context Debug Toggle */}
{lastWebContext && (
<div>
<button
type="button"
onClick={() => setShowContextDebug((prev) => !prev)}
className="flex items-center gap-2 text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
<Globe className="w-3 h-3" />
Debug: Contexto Web ({lastContextSources.length} fontes)
<ChevronDown className={`w-3 h-3 transition-transform ${showContextDebug ? 'rotate-180' : ''}`} />
</button>
{showContextDebug && (
<div className="mt-2 p-4 rounded-lg bg-muted/30 text-xs border border-muted/50 space-y-3">
<div className="max-h-32 overflow-y-auto space-y-1">
{lastContextSources.map((s, i) => (
<a key={i} href={s.url} target="_blank" className="block hover:underline text-primary truncate">{s.title || s.url}</a>
))}
</div>
<div className="border-t border-muted/20 pt-2">
<p className="mb-1 font-semibold">Preview Contexto:</p>
<pre className="max-h-32 overflow-y-auto p-2 bg-background rounded border text-[10px]">{lastWebContext.substring(0, 500)}...</pre>
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Spacer for Bottom Input */} {/* Spacer for Bottom Input */}
<div className="h-32 md:h-40 w-full shrink-0" ref={messagesEndRef} /> <div className="h-32 md:h-40 w-full shrink-0" ref={messagesEndRef} />
</div> </div>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+6
View File
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { AppUpdaterProvider } from "@/components/app-updater-provider"; import { AppUpdaterProvider } from "@/components/app-updater-provider";
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -17,6 +18,10 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "OllaHub", title: "OllaHub",
description: "Interface moderna para Ollama", description: "Interface moderna para Ollama",
icons: {
icon: "/icon.png",
apple: "/icon.png",
},
}; };
export default function RootLayout({ export default function RootLayout({
@@ -35,6 +40,7 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<KeyboardShortcuts />
{children} {children}
<AppUpdaterProvider /> <AppUpdaterProvider />
</ThemeProvider> </ThemeProvider>
+50 -27
View File
@@ -1,53 +1,63 @@
'use client'; 'use client';
import { useOllamaCheck } from "@/hooks/use-ollama-check"; import { useSetupCheck } from "@/hooks/use-setup-check";
import { Hero } from "@/components/landing/hero"; import { Hero } from "@/components/landing/hero";
import { InstallModal } from "@/components/modals/install-modal"; import { InstallModal } from "@/components/modals/install-modal";
import { StoppedCard } from "@/components/landing/stopped-card"; import { StoppedCard } from "@/components/landing/stopped-card";
import { WelcomeNotification } from "@/components/notifications/welcome-notification"; import { WelcomeNotification } from "@/components/notifications/welcome-notification";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect, startTransition } from "react";
export default function Home() { export default function Home() {
const { status, check } = useOllamaCheck(); const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck();
const router = useRouter(); const router = useRouter();
// Tentar iniciar Ollama automaticamente quando o componente montar // Redirecionar automaticamente se tudo estiver pronto
useEffect(() => { useEffect(() => {
const tryAutoStart = async () => { if (status === 'ready') {
try { const setupComplete = localStorage.getItem("ollahub_setup_complete");
const { invoke } = await import("@tauri-apps/api/core");
await invoke('auto_start_ollama');
// Aguardar um pouco e verificar novamente
setTimeout(() => {
check();
}, 2000);
} catch (error) {
console.error('Erro ao tentar iniciar Ollama automaticamente:', error);
}
};
// Só tentar se estiver instalado mas não rodando // Se setup não foi marcado como completo, marcar agora
if (status === 'installed_stopped') { if (!setupComplete) {
tryAutoStart(); localStorage.setItem("ollahub_setup_complete", "true");
} }
}, [status, check]);
// Redirecionar para chat após um breve delay para mostrar feedback
const timer = setTimeout(() => {
startTransition(() => {
router.push("/chat");
});
}, 500);
return () => clearTimeout(timer);
}
}, [status, router]);
const handleStart = () => { const handleStart = () => {
const setupComplete = localStorage.getItem("ollahub_setup_complete"); const setupComplete = localStorage.getItem("ollahub_setup_complete");
if (setupComplete) { if (setupComplete) {
startTransition(() => {
router.push("/chat"); router.push("/chat");
});
} else { } else {
startTransition(() => {
router.push("/setup"); router.push("/setup");
});
} }
}; };
// Mostrar loading durante verificação
if (status === 'checking') { if (status === 'checking') {
return ( return (
<div className="flex h-screen w-full items-center justify-center bg-background"> <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" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2 text-muted-foreground">Verificando sistema...</span> <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>
</div>
</div>
</div> </div>
); );
} }
@@ -57,24 +67,37 @@ export default function Home() {
{/* Notificação de boas-vindas */} {/* Notificação de boas-vindas */}
<WelcomeNotification /> <WelcomeNotification />
{/* Layout for Running State */} {/* Layout for Ready State - Redirecionamento automático */}
{status === 'running' && ( {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} /> <Hero onStart={handleStart} />
)} )}
{/* Layout for Stopped State */} {/* Layout for Stopped State */}
{status === 'installed_stopped' && ( {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="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"> <div className="text-center space-y-2">
<h1 className="text-3xl font-bold tracking-tighter">OllaHub</h1> <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> <p className="text-muted-foreground">Detectamos o Ollama, mas ele não está rodando.</p>
</div> </div>
<StoppedCard onCheckAgain={check} /> <StoppedCard onCheckAgain={recheck} />
</div> </div>
)} )}
{/* Layout for Not Installed State (Background + Modal) */} {/* Layout for Not Installed State (Background + Modal) */}
{status === 'not_installed' && ( {status === 'needs_ollama' && (
<> <>
<div className="opacity-20 pointer-events-none blur-sm"> <div className="opacity-20 pointer-events-none blur-sm">
<Hero onStart={() => {}} /> <Hero onStart={() => {}} />
@@ -82,7 +105,7 @@ export default function Home() {
{/* Hero is shown in background, modal is open */} {/* Hero is shown in background, modal is open */}
<InstallModal <InstallModal
open={true} open={true}
onCheckAgain={check} onCheckAgain={recheck}
/> />
</> </>
)} )}
+59 -3
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, lazy, Suspense } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -22,7 +22,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useSettingsStore } from '@/store/settings-store'; import { useSettingsStore } from '@/store/settings-store';
import { DomainTagsInput } from '@/components/settings/domain-tags-input'; import { DomainTagsInput } from '@/components/settings/domain-tags-input';
import { HardwareDashboard } from '@/components/settings/HardwareDashboard'; const HardwareDashboard = lazy(() => import('@/components/settings/HardwareDashboard').then(m => ({ default: m.HardwareDashboard })));
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign, Edit, RotateCcw, Terminal, Power, ArrowLeft, Sparkles, RefreshCw } from 'lucide-react'; import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign, Edit, RotateCcw, Terminal, Power, ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
@@ -55,6 +55,7 @@ export default function SettingsPage() {
const [recentLogs, setRecentLogs] = useState<string[]>([]); const [recentLogs, setRecentLogs] = useState<string[]>([]);
const [isLoadingLogs, setIsLoadingLogs] = useState(false); const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [appVersion, setAppVersion] = useState<string>(''); const [appVersion, setAppVersion] = useState<string>('');
const [activeTab, setActiveTab] = useState<string>('overview');
const updater = useAppUpdater(); const updater = useAppUpdater();
@@ -252,7 +253,7 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
<Tabs defaultValue="overview" className="space-y-4 sm:space-y-6"> <Tabs defaultValue="overview" value={activeTab} onValueChange={setActiveTab} className="space-y-4 sm:space-y-6">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-1 sm:gap-2 h-auto"> <TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-1 sm:gap-2 h-auto">
<TabsTrigger value="overview" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate"> <TabsTrigger value="overview" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
Visão Geral Visão Geral
@@ -284,7 +285,30 @@ export default function SettingsPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{activeTab === 'overview' && (
<Suspense fallback={
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[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>
</div>
}>
<HardwareDashboard /> <HardwareDashboard />
</Suspense>
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@@ -1106,6 +1130,38 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Debug & Desenvolvimento</CardTitle>
<CardDescription>
Ferramentas de debug e introspecção técnica
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="debug-mode">Modo Debug (God-Mode)</Label>
<p className="text-xs text-muted-foreground">
Exibe console completo de introspecção técnica para cada resposta da IA
</p>
</div>
<Switch
id="debug-mode"
checked={settings.debugMode}
onCheckedChange={settings.setDebugMode}
/>
</div>
{settings.debugMode && (
<div className="p-3 bg-muted/50 rounded-lg border border-border/50">
<p className="text-xs text-muted-foreground">
O console de debug mostrará: System Prompt, Histórico da Sessão, Deep Research Pipeline,
Dados Processados e Resposta Raw para cada mensagem do assistente.
</p>
</div>
)}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Atualizações</CardTitle> <CardTitle>Atualizações</CardTitle>
+7
View File
@@ -6,15 +6,22 @@ import { GpuSelector } from "@/components/setup/gpu-selector";
import { ModelSelector } from "@/components/setup/model-selector"; import { ModelSelector } from "@/components/setup/model-selector";
import { getRecommendation } from "@/lib/recommendation"; import { getRecommendation } from "@/lib/recommendation";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { startTransition, useState } from "react";
export default function SetupPage() { export default function SetupPage() {
const { specs, loading, gpus } = useHardware(); const { specs, loading, gpus } = useHardware();
const router = useRouter(); const router = useRouter();
const [isNavigating, setIsNavigating] = useState(false);
const handleComplete = () => { const handleComplete = () => {
setIsNavigating(true);
// Save setup state if needed // Save setup state if needed
localStorage.setItem("ollahub_setup_complete", "true"); localStorage.setItem("ollahub_setup_complete", "true");
// Usar startTransition para navegação não-bloqueante
startTransition(() => {
router.push("/chat"); router.push("/chat");
});
}; };
return ( return (
+28 -11
View File
@@ -8,7 +8,9 @@ import { markdownRenderers } from "./markdown-renderers";
import { MessageActions } from "./message-actions"; import { MessageActions } from "./message-actions";
import { MessageBackground } from "./message-background"; import { MessageBackground } from "./message-background";
import { MessageStepThinking } from "./message-step-thinking"; import { MessageStepThinking } from "./message-step-thinking";
import { DebugConsole, type DebugData } from "./debug-console";
import type { ThinkingMessageMetadata } from "@/hooks/use-chat"; import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
import { useSettingsStore } from "@/store/settings-store";
interface ChatMessageProps { interface ChatMessageProps {
message: Message; message: Message;
@@ -16,16 +18,17 @@ interface ChatMessageProps {
highlightTerm?: string; highlightTerm?: string;
highlightIndex?: number; highlightIndex?: number;
messageIndex?: number; messageIndex?: number;
debugData?: DebugData;
} }
/** /**
* Componente de mensagem do chat otimizado com React.memo * Componente de mensagem do chat otimizado com React.memo
* *
* Durante streaming, usa renderização simplificada (texto puro) para evitar * Renderiza markdown em tempo real durante streaming para melhor UX.
* recálculo custoso da árvore DOM e syntax highlight a cada token. * Usa useMemo para otimizar re-renders e manter performance.
* Markdown completo é aplicado apenas quando a mensagem está finalizada.
*/ */
function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) { function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex, debugData }: ChatMessageProps) {
const debugMode = useSettingsStore(state => state.debugMode);
// Verificar se é mensagem de processo de pensamento // Verificar se é mensagem de processo de pensamento
const isThinkingMessage = message.metadata && const isThinkingMessage = message.metadata &&
typeof message.metadata === 'object' && typeof message.metadata === 'object' &&
@@ -62,6 +65,9 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
return content || message.content; return content || message.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) // Processar highlight separadamente (após limpeza de conteúdo)
// Desabilitar durante streaming para performance // Desabilitar durante streaming para performance
const highlightedContent = useMemo(() => { const highlightedContent = useMemo(() => {
@@ -114,6 +120,7 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
}, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]); }, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]);
return ( return (
<>
<MessageBackground <MessageBackground
role={isUser ? 'user' : 'assistant'} role={isUser ? 'user' : 'assistant'}
state={selected ? 'selected' : 'normal'} state={selected ? 'selected' : 'normal'}
@@ -153,16 +160,21 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
<span className="text-sm font-medium">Gerando resposta...</span> <span className="text-sm font-medium">Gerando resposta...</span>
</div> </div>
) : isStreaming ? ( ) : isStreaming ? (
// Renderização LEVE durante streaming: texto simples com formatação básica // Renderização de markdown em tempo real durante streaming
// Evita recálculo de ReactMarkdown e syntax highlight a cada token
<div className={cn( <div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90", "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-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" "relative"
)}> )}>
<div className="whitespace-pre-wrap font-sans"> <ReactMarkdown
{processedContent} remarkPlugins={[remarkGfm]}
</div> components={markdownRenderers}
>
{markdownContent}
</ReactMarkdown>
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" /> <span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
</div> </div>
) : ( ) : (
@@ -199,6 +211,10 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
</div> </div>
)} )}
</MessageBackground> </MessageBackground>
{!isUser && !isStreaming && debugMode && debugData && (
<DebugConsole data={debugData} />
)}
</>
); );
} }
@@ -227,7 +243,8 @@ export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProp
prevProps.message.role === nextProps.message.role && prevProps.message.role === nextProps.message.role &&
prevProps.highlightTerm === nextProps.highlightTerm && prevProps.highlightTerm === nextProps.highlightTerm &&
prevProps.highlightIndex === nextProps.highlightIndex && prevProps.highlightIndex === nextProps.highlightIndex &&
prevProps.messageIndex === nextProps.messageIndex prevProps.messageIndex === nextProps.messageIndex &&
prevProps.debugData === nextProps.debugData
); );
}); });
+475
View File
@@ -0,0 +1,475 @@
'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';
import { Message } from '@/hooks/use-chat';
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[];
logs: Array<{
stage: string;
timestamp: number;
input: string;
rawOutput?: string;
parsedOutput?: any;
error?: string;
}>;
plan?: string[];
knowledgeBase?: Array<{
sourceUrl: string;
title: string;
content: string;
}>;
};
deepResearchState?: DeepResearchState;
finalResponse: string;
rawResponse?: string;
tokenUsage?: {
input: number;
output: number;
};
}
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>
);
}
+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;
}
+25 -3
View File
@@ -43,11 +43,33 @@ export function HardwareDashboard() {
if (isLoading || !stats) { if (isLoading || !stats) {
return ( return (
<Card> <div className="space-y-4">
<CardContent className="py-8 text-center text-muted-foreground"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
Carregando estatísticas do sistema... {/* 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> </CardContent>
</Card> </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>
); );
} }
+27 -3
View File
@@ -8,6 +8,7 @@ import { RECOMMENDED_MODELS, ModelRecommendation } from "@/lib/recommendation";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { startTransition } from "react";
interface ModelSelectorProps { interface ModelSelectorProps {
recommendation: ModelRecommendation; recommendation: ModelRecommendation;
@@ -30,6 +31,7 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
const [downloadProgress, setDownloadProgress] = useState(0); const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadStatus, setDownloadStatus] = useState(""); const [downloadStatus, setDownloadStatus] = useState("");
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null); const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
const [isNavigating, setIsNavigating] = useState(false);
const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0]; const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0];
@@ -80,10 +82,11 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay // Se chegou a 100% ou success, marcar como instalado após um pequeno delay
if (info.percent === 100 || info.status === "success") { if (info.percent === 100 || info.status === "success") {
setTimeout(() => { setTimeout(async () => {
setIsInstalled(true); setIsInstalled(true);
setIsDownloading(false); setIsDownloading(false);
checkInstallation(selectedModelId); // Verificar instalação de forma assíncrona sem bloquear
checkInstallation(selectedModelId).catch(console.error);
}, 500); }, 500);
} }
} }
@@ -103,6 +106,14 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
} }
}; };
const handleNavigate = () => {
setIsNavigating(true);
// Usar startTransition para navegação não-bloqueante
startTransition(() => {
onComplete();
});
};
const handleDownload = async () => { const handleDownload = async () => {
setIsDownloading(true); setIsDownloading(true);
setDownloadProgress(0); setDownloadProgress(0);
@@ -192,9 +203,22 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
</CardContent> </CardContent>
<CardFooter> <CardFooter>
{isInstalled ? ( {isInstalled ? (
<Button className="w-full h-12 text-lg" onClick={onComplete}> <Button
className="w-full h-12 text-lg"
onClick={handleNavigate}
disabled={isNavigating}
>
{isNavigating ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Inicializando...
</>
) : (
<>
Ir para o Chat Ir para o Chat
<Play className="ml-2 w-5 h-5" /> <Play className="ml-2 w-5 h-5" />
</>
)}
</Button> </Button>
) : ( ) : (
<Button <Button
+6
View File
@@ -85,6 +85,12 @@ export function TitleBar() {
className="flex items-center gap-2 px-4 h-full flex-1" className="flex items-center gap-2 px-4 h-full flex-1"
data-tauri-drag-region data-tauri-drag-region
> >
<img
src="/icon.png"
alt="OllaHub"
className="w-5 h-5 shrink-0"
draggable={false}
/>
<span className="text-sm font-medium text-sidebar-foreground"> <span className="text-sm font-medium text-sidebar-foreground">
OllaHub OllaHub
</span> </span>
-3
View File
@@ -10,11 +10,8 @@ export function useHardware() {
async function fetchSpecs() { async function fetchSpecs() {
try { try {
const data = await invoke<SystemSpecs>('get_system_specs'); const data = await invoke<SystemSpecs>('get_system_specs');
// Simulate a small delay for the "scanning" effect
setTimeout(() => {
setSpecs(data); setSpecs(data);
setLoading(false); setLoading(false);
}, 1500);
} catch (error) { } catch (error) {
console.error("Failed to get system specs:", error); console.error("Failed to get system specs:", error);
setLoading(false); setLoading(false);
+111
View File
@@ -0,0 +1,111 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useOllamaCheck, type OllamaStatus } from './use-ollama-check';
export type SetupStatus = 'checking' | 'ready' | 'needs_setup' | 'needs_ollama';
export function useSetupCheck() {
const { status: ollamaStatus, check: checkOllama } = useOllamaCheck();
const [setupStatus, setSetupStatus] = useState<SetupStatus>('checking');
const [checkingStep, setCheckingStep] = useState<string>('Verificando Ollama...');
const [hasModels, setHasModels] = useState(false);
const [modelsCount, setModelsCount] = useState(0);
const checkModels = async (): Promise<boolean> => {
try {
setCheckingStep('Verificando modelos instalados...');
const models = await invoke<Array<{ name: string; size: string; id: string; modified_at: string }>>('list_local_models');
const count = models.length;
setModelsCount(count);
const hasAny = count > 0;
setHasModels(hasAny);
return hasAny;
} catch (error) {
console.error('Failed to check models:', error);
// Em caso de erro, assumir que não há modelos (seguro)
setHasModels(false);
setModelsCount(0);
return false;
}
};
const determineSetupStatus = async (ollamaStatus: OllamaStatus): Promise<SetupStatus> => {
// Se Ollama não está instalado, precisa instalar
if (ollamaStatus === 'not_installed') {
return 'needs_ollama';
}
// Se Ollama está instalado mas parado, tentar iniciar primeiro
if (ollamaStatus === 'installed_stopped') {
try {
setCheckingStep('Iniciando Ollama...');
await invoke('auto_start_ollama');
// Aguardar um pouco para o servidor iniciar
await new Promise(resolve => setTimeout(resolve, 2000));
// Verificar novamente se está rodando
const running = await invoke<boolean>('check_ollama_running');
if (!running) {
// Ainda não está rodando, precisa de intervenção do usuário
return 'needs_setup';
}
// Agora está rodando, verificar modelos
const hasAnyModels = await checkModels();
return hasAnyModels ? 'ready' : 'needs_setup';
} catch (error) {
console.error('Failed to auto-start Ollama:', error);
return 'needs_setup';
}
}
// Se Ollama está rodando, verificar modelos
if (ollamaStatus === 'running') {
const hasAnyModels = await checkModels();
return hasAnyModels ? 'ready' : 'needs_setup';
}
// Estado checking ou qualquer outro caso
return 'checking';
};
useEffect(() => {
const performCheck = async () => {
if (ollamaStatus === 'checking') {
setSetupStatus('checking');
setCheckingStep('Verificando Ollama...');
return;
}
const status = await determineSetupStatus(ollamaStatus);
setSetupStatus(status);
if (status === 'ready') {
setCheckingStep('Tudo pronto!');
} else if (status === 'needs_setup') {
setCheckingStep('Configuração necessária');
} else if (status === 'needs_ollama') {
setCheckingStep('Ollama não instalado');
}
};
performCheck();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ollamaStatus]);
const recheck = async () => {
setSetupStatus('checking');
setCheckingStep('Verificando Ollama...');
await checkOllama();
// O useEffect vai processar o novo status
};
return {
status: setupStatus,
ollamaStatus,
hasModels,
modelsCount,
checkingStep,
recheck,
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+19 -1
View File
@@ -6,7 +6,7 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::{command, Window, Emitter, Manager, AppHandle, State}; use tauri::{command, Window, Emitter, Manager, AppHandle, State, WebviewWindow};
use sysinfo::System; use sysinfo::System;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use zip::write::{FileOptions, ZipWriter}; use zip::write::{FileOptions, ZipWriter};
@@ -328,6 +328,23 @@ fn get_mcp_config_path(app_handle: &AppHandle) -> Result<PathBuf, String> {
Ok(app_data_dir.join("mcp_config.json")) Ok(app_data_dir.join("mcp_config.json"))
} }
#[command]
fn toggle_devtools(window: WebviewWindow) -> Result<(), String> {
#[cfg(debug_assertions)]
{
if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools();
}
}
#[cfg(not(debug_assertions))]
{
// Em release, não fazer nada
}
Ok(())
}
#[command] #[command]
fn save_chat_session( fn save_chat_session(
app_handle: AppHandle, app_handle: AppHandle,
@@ -3697,6 +3714,7 @@ pub fn run() {
delete_model, delete_model,
save_chat_session, save_chat_session,
load_chat_sessions, load_chat_sessions,
toggle_devtools,
search_chat_sessions, search_chat_sessions,
load_chat_history, load_chat_history,
load_chat_history_paginated, load_chat_history_paginated,
+26 -8
View File
@@ -210,6 +210,7 @@ pub fn detect_all_gpus() -> Vec<GpuInfo> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn detect_gpus_windows() -> Vec<GpuInfo> { fn detect_gpus_windows() -> Vec<GpuInfo> {
use std::process::Command; use std::process::Command;
use std::os::windows::process::CommandExt;
let mut gpus = Vec::new(); let mut gpus = Vec::new();
log::info!("Tentando detectar GPUs via wmic..."); log::info!("Tentando detectar GPUs via wmic...");
@@ -217,6 +218,7 @@ fn detect_gpus_windows() -> Vec<GpuInfo> {
// Tentar formato CSV primeiro (mais confiável) // Tentar formato CSV primeiro (mais confiável)
if let Ok(output) = Command::new("wmic") if let Ok(output) = Command::new("wmic")
.args(&["path", "win32_VideoController", "get", "name,AdapterRAM,PNPDeviceID", "/format:csv"]) .args(&["path", "win32_VideoController", "get", "name,AdapterRAM,PNPDeviceID", "/format:csv"])
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output() .output()
{ {
// wmic pode retornar UTF-16LE no Windows, precisamos converter // wmic pode retornar UTF-16LE no Windows, precisamos converter
@@ -282,6 +284,7 @@ fn detect_gpus_windows() -> Vec<GpuInfo> {
// Fallback para formato list // Fallback para formato list
if let Ok(output) = Command::new("wmic") if let Ok(output) = Command::new("wmic")
.args(&["path", "win32_VideoController", "get", "name,AdapterRAM,PNPDeviceID", "/format:list"]) .args(&["path", "win32_VideoController", "get", "name,AdapterRAM,PNPDeviceID", "/format:list"])
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output() .output()
{ {
if let Ok(stdout) = String::from_utf8(output.stdout) { if let Ok(stdout) = String::from_utf8(output.stdout) {
@@ -337,11 +340,13 @@ fn detect_gpus_windows() -> Vec<GpuInfo> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn detect_gpus_nvidia_smi() -> Result<Vec<GpuInfo>, String> { fn detect_gpus_nvidia_smi() -> Result<Vec<GpuInfo>, String> {
use std::process::Command; use std::process::Command;
use std::os::windows::process::CommandExt;
log::info!("Executando nvidia-smi..."); log::info!("Executando nvidia-smi...");
let output = Command::new("nvidia-smi") let output = Command::new("nvidia-smi")
.args(&["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"]) .args(&["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output() .output()
.map_err(|e| format!("nvidia-smi não encontrado: {}", e))?; .map_err(|e| format!("nvidia-smi não encontrado: {}", e))?;
@@ -678,13 +683,19 @@ fn get_nvidia_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
// Query nvidia-smi para obter todas as métricas // Query nvidia-smi para obter todas as métricas
let query = "name,memory.used,memory.total,utilization.gpu,utilization.memory,temperature.gpu,temperature.max,power.draw,power.limit,fan.speed,driver_version"; let query = "name,memory.used,memory.total,utilization.gpu,utilization.memory,temperature.gpu,temperature.max,power.draw,power.limit,fan.speed,driver_version";
let output = Command::new("nvidia-smi") #[cfg(target_os = "windows")]
.args(&[ use std::os::windows::process::CommandExt;
let mut cmd = Command::new("nvidia-smi");
cmd.args(&[
"--query-gpu", query, "--query-gpu", query,
"--format=csv,noheader,nounits" "--format=csv,noheader,nounits"
]) ]);
.output()
.ok()?; #[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let output = cmd.output().ok()?;
if !output.status.success() { if !output.status.success() {
log::warn!("nvidia-smi falhou ao coletar stats"); log::warn!("nvidia-smi falhou ao coletar stats");
@@ -757,9 +768,16 @@ fn get_nvidia_gpu_stats(gpu: &GpuInfo) -> Option<GpuStats> {
fn count_nvidia_gpu_processes() -> Result<usize, String> { fn count_nvidia_gpu_processes() -> Result<usize, String> {
use std::process::Command; use std::process::Command;
let output = Command::new("nvidia-smi") #[cfg(target_os = "windows")]
.args(&["--query-compute-apps=pid", "--format=csv,noheader"]) use std::os::windows::process::CommandExt;
.output()
let mut cmd = Command::new("nvidia-smi");
cmd.args(&["--query-compute-apps=pid", "--format=csv,noheader"]);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let output = cmd.output()
.map_err(|e| format!("nvidia-smi não encontrado: {}", e))?; .map_err(|e| format!("nvidia-smi não encontrado: {}", e))?;
if !output.status.success() { if !output.status.success() {
+8 -6
View File
@@ -18,7 +18,8 @@
"resizable": true, "resizable": true,
"fullscreen": false, "fullscreen": false,
"decorations": false, "decorations": false,
"transparent": false "transparent": false,
"devtools": false
} }
], ],
"security": { "security": {
@@ -29,11 +30,12 @@
"active": true, "active": true,
"targets": "all", "targets": "all",
"icon": [ "icon": [
"icons/32x32.png", "icons/icon.ico",
"icons/128x128.png", "icons/32x32.ico",
"icons/128x128@2x.png", "icons/128x128.ico",
"icons/icon.icns", "icons/256x256.ico",
"icons/icon.ico" "icons/512x512.ico",
"icons/icon.icns"
] ]
}, },
"plugins": { "plugins": {
+21 -3
View File
@@ -59,6 +59,9 @@ export interface SettingsState {
// Updates // Updates
autoCheckUpdates: boolean; autoCheckUpdates: boolean;
// Debug
debugMode: boolean;
// Query Preprocessing // Query Preprocessing
queryPreprocessing: { queryPreprocessing: {
enabled: boolean; enabled: boolean;
@@ -102,6 +105,9 @@ export interface SettingsState {
// Updates Actions // Updates Actions
setAutoCheckUpdates: (enabled: boolean) => void; setAutoCheckUpdates: (enabled: boolean) => void;
// Debug Actions
setDebugMode: (enabled: boolean) => void;
// Query Preprocessing Actions // Query Preprocessing Actions
setQueryPreprocessingEnabled: (enabled: boolean) => void; setQueryPreprocessingEnabled: (enabled: boolean) => void;
setQueryPreprocessingMinLength: (min: number) => void; setQueryPreprocessingMinLength: (min: number) => void;
@@ -161,7 +167,7 @@ const defaultCategories: SearchCategory[] = [
'pubmed.ncbi.nlm.nih.gov', 'pubmed.ncbi.nlm.nih.gov',
'researchgate.net', 'researchgate.net',
], ],
enabled: false, enabled: true,
}, },
{ {
id: 'news', id: 'news',
@@ -197,7 +203,7 @@ const defaultCategories: SearchCategory[] = [
'investing.com', 'investing.com',
'yahoo.com/finance', 'yahoo.com/finance',
], ],
enabled: false, enabled: true,
}, },
]; ];
@@ -228,6 +234,7 @@ const initialState = {
keywords: [], keywords: [],
}, },
autoCheckUpdates: true, autoCheckUpdates: true,
debugMode: false,
queryPreprocessing: { queryPreprocessing: {
enabled: true, enabled: true,
minLength: 3, minLength: 3,
@@ -418,6 +425,10 @@ export const useSettingsStore = create<SettingsState>()(
setAutoCheckUpdates: (enabled) => setAutoCheckUpdates: (enabled) =>
set({ autoCheckUpdates: enabled }), set({ autoCheckUpdates: enabled }),
// Debug Actions
setDebugMode: (enabled) =>
set({ debugMode: enabled }),
// Query Preprocessing Actions // Query Preprocessing Actions
setQueryPreprocessingEnabled: (enabled) => setQueryPreprocessingEnabled: (enabled) =>
set((state) => ({ set((state) => ({
@@ -490,7 +501,7 @@ export const useSettingsStore = create<SettingsState>()(
}), }),
{ {
name: 'ollahub-settings', name: 'ollahub-settings',
version: 3, version: 4,
migrate: (persistedState: any, version: number) => { migrate: (persistedState: any, version: number) => {
// Migração da versão 1 para 3 // Migração da versão 1 para 3
if (version < 3) { if (version < 3) {
@@ -499,6 +510,13 @@ export const useSettingsStore = create<SettingsState>()(
autoCheckUpdates: persistedState.autoCheckUpdates ?? true, autoCheckUpdates: persistedState.autoCheckUpdates ?? true,
}; };
} }
// Migração da versão 3 para 4
if (version < 4) {
return {
...persistedState,
debugMode: persistedState.debugMode ?? false,
};
}
return persistedState; return persistedState;
}, },
} }