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:
1 parent
55749c07e3
commit
c7da6cb8a8
36 files changed
+1458
-293
No files matched your search
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user