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

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