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

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