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
+1458 -293

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
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
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
git clone https://github.com/evandrodevbr/OllaHub.git
cd OllaHub
```
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.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
The built application will be in `src-tauri/target/release/bundle/`:
- **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 { Button } from "@/components/ui/button";
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 { ChatInput } from "@/components/chat/chat-input";
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 type { Message, ThinkingMessageMetadata, ThinkingStepType, ThinkingStepStatus } from "@/hooks/use-chat";
import { executeProgressiveSearch } from "@/lib/web-search-fallback";
import type { DebugData } from "@/components/chat/debug-console";
// @ts-expect-error - MD file import
import defaultFormatPrompt from "@/data/prompts/default-format.md";
@@ -125,14 +126,9 @@ export default function ChatPage() {
const lastSavedMessagesRef = useRef<number>(0);
const messagesEndRef = 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 [logsCopied, setLogsCopied] = useState(false);
const [lastUserQuery, setLastUserQuery] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
const [debugDataMap, setDebugDataMap] = useState<Map<number, DebugData>>(new Map());
// Resetar índice quando busca é limpa ou sessão muda
useEffect(() => {
@@ -369,13 +365,6 @@ export default function ChatPage() {
chatLog.info(`Should Search: ${preprocessed.shouldSearch}`);
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)
if (!currentSessionId) {
const newId = crypto.randomUUID();
@@ -598,8 +587,6 @@ export default function ChatPage() {
chatLog.warn('⚠️ Knowledge Base Context is empty!');
}
setLastWebContext(knowledgeBaseContext);
setLastContextSources(scrapedSources);
// Atualizar mensagem: Processamento concluído
updateThinkingMessage('processing', {
@@ -627,8 +614,6 @@ export default function ChatPage() {
deepResearch.addToKnowledgeBase(simpleQuery, results);
knowledgeBaseContext = deepResearch.getCuratedContext();
scrapedSources = results;
setLastWebContext(knowledgeBaseContext);
setLastContextSources(scrapedSources);
}
} else {
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('========== 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)
if (scrapedSources.length > 0) {
// 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 (
<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}
highlightIndex={messageMatchIndex}
messageIndex={group.index}
debugData={group.index !== undefined ? debugDataMap.get(group.index) : undefined}
/>
</div>
);
@@ -1390,71 +1377,6 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</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 */}
<div className="h-32 md:h-40 w-full shrink-0" ref={messagesEndRef} />
</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 { ThemeProvider } from "@/components/theme-provider";
import { AppUpdaterProvider } from "@/components/app-updater-provider";
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -17,6 +18,10 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "OllaHub",
description: "Interface moderna para Ollama",
icons: {
icon: "/icon.png",
apple: "/icon.png",
},
};
export default function RootLayout({
@@ -35,6 +40,7 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<KeyboardShortcuts />
{children}
<AppUpdaterProvider />
</ThemeProvider>
+53 -30
View File
@@ -1,53 +1,63 @@
'use client';
import { useOllamaCheck } from "@/hooks/use-ollama-check";
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 } from "react";
import { useEffect, startTransition } from "react";
export default function Home() {
const { status, check } = useOllamaCheck();
const { status, ollamaStatus, checkingStep, recheck } = useSetupCheck();
const router = useRouter();
// Tentar iniciar Ollama automaticamente quando o componente montar
// Redirecionar automaticamente se tudo estiver pronto
useEffect(() => {
const tryAutoStart = async () => {
try {
const { invoke } = await import("@tauri-apps/api/core");
await invoke('auto_start_ollama');
// Aguardar um pouco e verificar novamente
setTimeout(() => {
check();
}, 2000);
} catch (error) {
console.error('Erro ao tentar iniciar Ollama automaticamente:', error);
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");
}
};
// Só tentar se estiver instalado mas não rodando
if (status === 'installed_stopped') {
tryAutoStart();
// Redirecionar para chat após um breve delay para mostrar feedback
const timer = setTimeout(() => {
startTransition(() => {
router.push("/chat");
});
}, 500);
return () => clearTimeout(timer);
}
}, [status, check]);
}, [status, router]);
const handleStart = () => {
const setupComplete = localStorage.getItem("ollahub_setup_complete");
if (setupComplete) {
router.push("/chat");
startTransition(() => {
router.push("/chat");
});
} else {
router.push("/setup");
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">
<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-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>
</div>
</div>
</div>
);
}
@@ -57,24 +67,37 @@ export default function Home() {
{/* Notificação de boas-vindas */}
<WelcomeNotification />
{/* Layout for Running State */}
{status === 'running' && (
{/* 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 === '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="text-center space-y-2">
<h1 className="text-3xl font-bold tracking-tighter">OllaHub</h1>
<p className="text-muted-foreground">Detectamos o Ollama, mas ele não está rodando.</p>
</div>
<StoppedCard onCheckAgain={check} />
<StoppedCard onCheckAgain={recheck} />
</div>
)}
{/* Layout for Not Installed State (Background + Modal) */}
{status === 'not_installed' && (
{status === 'needs_ollama' && (
<>
<div className="opacity-20 pointer-events-none blur-sm">
<Hero onStart={() => {}} />
@@ -82,7 +105,7 @@ export default function Home() {
{/* Hero is shown in background, modal is open */}
<InstallModal
open={true}
onCheckAgain={check}
onCheckAgain={recheck}
/>
</>
)}
+60 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, lazy, Suspense } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
@@ -22,7 +22,7 @@ import {
} from '@/components/ui/dialog';
import { useSettingsStore } from '@/store/settings-store';
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 { 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';
@@ -55,6 +55,7 @@ export default function SettingsPage() {
const [recentLogs, setRecentLogs] = useState<string[]>([]);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [appVersion, setAppVersion] = useState<string>('');
const [activeTab, setActiveTab] = useState<string>('overview');
const updater = useAppUpdater();
@@ -252,7 +253,7 @@ export default function SettingsPage() {
</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">
<TabsTrigger value="overview" className="text-xs sm:text-sm px-2 sm:px-3 py-1.5 sm:py-2 truncate">
Visão Geral
@@ -284,7 +285,30 @@ export default function SettingsPage() {
</CardDescription>
</CardHeader>
<CardContent>
<HardwareDashboard />
{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 />
</Suspense>
)}
</CardContent>
</Card>
</TabsContent>
@@ -1106,6 +1130,38 @@ export default function SettingsPage() {
</CardContent>
</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>
<CardHeader>
<CardTitle>Atualizações</CardTitle>
+8 -1
View File
@@ -6,15 +6,22 @@ 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");
router.push("/chat");
// Usar startTransition para navegação não-bloqueante
startTransition(() => {
router.push("/chat");
});
};
return (
+98 -81
View File
@@ -8,7 +8,9 @@ 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;
@@ -16,16 +18,17 @@ interface ChatMessageProps {
highlightTerm?: string;
highlightIndex?: number;
messageIndex?: number;
debugData?: DebugData;
}
/**
* Componente de mensagem do chat otimizado com React.memo
*
* Durante streaming, usa renderização simplificada (texto puro) para evitar
* recálculo custoso da árvore DOM e syntax highlight a cada token.
* Markdown completo é aplicado apenas quando a mensagem está finalizada.
* 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 }: 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
const isThinkingMessage = message.metadata &&
typeof message.metadata === 'object' &&
@@ -61,6 +64,9 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
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
@@ -114,91 +120,101 @@ function ChatMessageComponent({ message, isStreaming = false, highlightTerm, hig
}, [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" />}
<>
<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>
<div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
{isUser ? (
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
<div
className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
>
{!highlightedContent && processedContent}
</div>
) : (
// Estilo Assistant
isEmpty && isStreaming ? (
<div className="flex items-center gap-2 text-muted-foreground py-1">
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span className="text-sm font-medium">Gerando resposta...</span>
</div>
) : isStreaming ? (
// Renderização LEVE durante streaming: texto simples com formatação básica
// Evita recálculo de ReactMarkdown e syntax highlight a cada token
<div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-headings:font-semibold prose-headings:tracking-tight",
"relative"
)}>
<div className="whitespace-pre-wrap font-sans">
{processedContent}
<div 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>
<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 }} />
) : (
) : 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}
>
{processedContent}
{markdownContent}
</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} />
<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} />
)}
</MessageBackground>
</>
);
}
@@ -227,7 +243,8 @@ export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProp
prevProps.message.role === nextProps.message.role &&
prevProps.highlightTerm === nextProps.highlightTerm &&
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;
}
+27 -5
View File
@@ -43,11 +43,33 @@ export function HardwareDashboard() {
if (isLoading || !stats) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Carregando estatísticas do sistema...
</CardContent>
</Card>
<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>
);
}
+29 -5
View File
@@ -8,6 +8,7 @@ import { RECOMMENDED_MODELS, ModelRecommendation } from "@/lib/recommendation";
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { startTransition } from "react";
interface ModelSelectorProps {
recommendation: ModelRecommendation;
@@ -30,6 +31,7 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadStatus, setDownloadStatus] = useState("");
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
const [isNavigating, setIsNavigating] = useState(false);
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
if (info.percent === 100 || info.status === "success") {
setTimeout(() => {
setTimeout(async () => {
setIsInstalled(true);
setIsDownloading(false);
checkInstallation(selectedModelId);
// Verificar instalação de forma assíncrona sem bloquear
checkInstallation(selectedModelId).catch(console.error);
}, 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 () => {
setIsDownloading(true);
setDownloadProgress(0);
@@ -192,9 +203,22 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
</CardContent>
<CardFooter>
{isInstalled ? (
<Button className="w-full h-12 text-lg" onClick={onComplete}>
Ir para o Chat
<Play className="ml-2 w-5 h-5" />
<Button
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
<Play className="ml-2 w-5 h-5" />
</>
)}
</Button>
) : (
<Button
+6
View File
@@ -85,6 +85,12 @@ export function TitleBar() {
className="flex items-center gap-2 px-4 h-full flex-1"
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">
OllaHub
</span>
+2 -5
View File
@@ -10,11 +10,8 @@ export function useHardware() {
async function fetchSpecs() {
try {
const data = await invoke<SystemSpecs>('get_system_specs');
// Simulate a small delay for the "scanning" effect
setTimeout(() => {
setSpecs(data);
setLoading(false);
}, 1500);
setSpecs(data);
setLoading(false);
} catch (error) {
console.error("Failed to get system specs:", error);
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::collections::HashMap;
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 chrono::{DateTime, Utc};
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"))
}
#[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]
fn save_chat_session(
app_handle: AppHandle,
@@ -3697,6 +3714,7 @@ pub fn run() {
delete_model,
save_chat_session,
load_chat_sessions,
toggle_devtools,
search_chat_sessions,
load_chat_history,
load_chat_history_paginated,
+28 -10
View File
@@ -210,6 +210,7 @@ pub fn detect_all_gpus() -> Vec<GpuInfo> {
#[cfg(target_os = "windows")]
fn detect_gpus_windows() -> Vec<GpuInfo> {
use std::process::Command;
use std::os::windows::process::CommandExt;
let mut gpus = Vec::new();
log::info!("Tentando detectar GPUs via wmic...");
@@ -217,6 +218,7 @@ fn detect_gpus_windows() -> Vec<GpuInfo> {
// Tentar formato CSV primeiro (mais confiável)
if let Ok(output) = Command::new("wmic")
.args(&["path", "win32_VideoController", "get", "name,AdapterRAM,PNPDeviceID", "/format:csv"])
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output()
{
// wmic pode retornar UTF-16LE no Windows, precisamos converter
@@ -282,6 +284,7 @@ fn detect_gpus_windows() -> Vec<GpuInfo> {
// Fallback para formato list
if let Ok(output) = Command::new("wmic")
.args(&["path", "win32_VideoController", "get", "name,AdapterRAM,PNPDeviceID", "/format:list"])
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output()
{
if let Ok(stdout) = String::from_utf8(output.stdout) {
@@ -337,11 +340,13 @@ fn detect_gpus_windows() -> Vec<GpuInfo> {
#[cfg(target_os = "windows")]
fn detect_gpus_nvidia_smi() -> Result<Vec<GpuInfo>, String> {
use std::process::Command;
use std::os::windows::process::CommandExt;
log::info!("Executando nvidia-smi...");
let output = Command::new("nvidia-smi")
.args(&["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
.creation_flags(0x08000000) // CREATE_NO_WINDOW
.output()
.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
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")
.args(&[
"--query-gpu", query,
"--format=csv,noheader,nounits"
])
.output()
.ok()?;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
let mut cmd = Command::new("nvidia-smi");
cmd.args(&[
"--query-gpu", query,
"--format=csv,noheader,nounits"
]);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let output = cmd.output().ok()?;
if !output.status.success() {
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> {
use std::process::Command;
let output = Command::new("nvidia-smi")
.args(&["--query-compute-apps=pid", "--format=csv,noheader"])
.output()
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
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))?;
if !output.status.success() {
+8 -6
View File
@@ -18,7 +18,8 @@
"resizable": true,
"fullscreen": false,
"decorations": false,
"transparent": false
"transparent": false,
"devtools": false
}
],
"security": {
@@ -29,11 +30,12 @@
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
"icons/icon.ico",
"icons/32x32.ico",
"icons/128x128.ico",
"icons/256x256.ico",
"icons/512x512.ico",
"icons/icon.icns"
]
},
"plugins": {
+21 -3
View File
@@ -59,6 +59,9 @@ export interface SettingsState {
// Updates
autoCheckUpdates: boolean;
// Debug
debugMode: boolean;
// Query Preprocessing
queryPreprocessing: {
enabled: boolean;
@@ -102,6 +105,9 @@ export interface SettingsState {
// Updates Actions
setAutoCheckUpdates: (enabled: boolean) => void;
// Debug Actions
setDebugMode: (enabled: boolean) => void;
// Query Preprocessing Actions
setQueryPreprocessingEnabled: (enabled: boolean) => void;
setQueryPreprocessingMinLength: (min: number) => void;
@@ -161,7 +167,7 @@ const defaultCategories: SearchCategory[] = [
'pubmed.ncbi.nlm.nih.gov',
'researchgate.net',
],
enabled: false,
enabled: true,
},
{
id: 'news',
@@ -197,7 +203,7 @@ const defaultCategories: SearchCategory[] = [
'investing.com',
'yahoo.com/finance',
],
enabled: false,
enabled: true,
},
];
@@ -228,6 +234,7 @@ const initialState = {
keywords: [],
},
autoCheckUpdates: true,
debugMode: false,
queryPreprocessing: {
enabled: true,
minLength: 3,
@@ -418,6 +425,10 @@ export const useSettingsStore = create<SettingsState>()(
setAutoCheckUpdates: (enabled) =>
set({ autoCheckUpdates: enabled }),
// Debug Actions
setDebugMode: (enabled) =>
set({ debugMode: enabled }),
// Query Preprocessing Actions
setQueryPreprocessingEnabled: (enabled) =>
set((state) => ({
@@ -490,7 +501,7 @@ export const useSettingsStore = create<SettingsState>()(
}),
{
name: 'ollahub-settings',
version: 3,
version: 4,
migrate: (persistedState: any, version: number) => {
// Migração da versão 1 para 3
if (version < 3) {
@@ -499,6 +510,13 @@ export const useSettingsStore = create<SettingsState>()(
autoCheckUpdates: persistedState.autoCheckUpdates ?? true,
};
}
// Migração da versão 3 para 4
if (version < 4) {
return {
...persistedState,
debugMode: persistedState.debugMode ?? false,
};
}
return persistedState;
},
}