fix: address issues in model pull and chat API

- Refactored model pull logic to improve error handling and user feedback.
- Enhanced chat API to ensure better performance and reliability during message fetching.
- Updated UI components for a more intuitive user experience when interacting with models and messages.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-19 00:01:14 -03:00
1 parent ad9d5d32d2
commit b2a06ed994
261 files changed
+19968

No files matched your search

+208
View File
@@ -0,0 +1,208 @@
'use client';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { ChatInput } from "@/components/chat/chat-input";
import { ChatMessage } from "@/components/chat/chat-message";
import { SystemPanel } from "@/components/chat/system-panel";
import { PromptGeneratorDialog } from "@/components/chat/prompt-generator";
import { useChat } from "@/hooks/use-chat";
import { useLocalModels } from "@/hooks/use-local-models";
import { useState, useEffect, useRef } from "react";
import { useTheme } from "next-themes";
import { ImperativePanelHandle } from "react-resizable-panels";
export default function ChatPage() {
const { messages, sendMessage, isLoading, stop } = useChat();
const { models } = useLocalModels();
const { theme, setTheme } = useTheme();
const [selectedModel, setSelectedModel] = useState("");
const [systemPrompt, setSystemPrompt] = useState("Você é um assistente útil e prestativo.");
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const sidebarRef = useRef<ImperativePanelHandle>(null);
// Auto-select first model
useEffect(() => {
if (models.length > 0 && !selectedModel) {
setSelectedModel(models[0].name);
}
}, [models, selectedModel]);
const handleSend = (content: string) => {
if (!selectedModel) return;
sendMessage(content, selectedModel, systemPrompt);
};
const toggleSidebar = () => {
const panel = sidebarRef.current;
if (panel) {
if (isSidebarCollapsed) {
panel.expand();
} else {
panel.collapse();
}
}
};
return (
<div className="h-screen w-full bg-background overflow-hidden flex">
{/* Left Sidebar (Nav) */}
<ResizablePanelGroup direction="horizontal" className="flex-1">
<ResizablePanel
ref={sidebarRef}
defaultSize={4}
minSize={4}
maxSize={15}
collapsible={true}
collapsedSize={0}
onCollapse={() => setIsSidebarCollapsed(true)}
onExpand={() => setIsSidebarCollapsed(false)}
className="border-r flex flex-col items-center py-4 gap-4 bg-muted/20 min-w-[60px]"
>
<Button variant="ghost" size="icon" className="rounded-lg bg-primary/10 text-primary">
<MessageSquare className="w-5 h-5" />
</Button>
<Button variant="ghost" size="icon" className="rounded-lg text-muted-foreground hover:text-foreground">
<Server className="w-5 h-5" />
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="rounded-lg text-muted-foreground hover:text-foreground"
>
{theme === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</Button>
<Button variant="ghost" size="icon" className="rounded-lg text-muted-foreground hover:text-foreground">
<Settings className="w-5 h-5" />
</Button>
</ResizablePanel>
<ResizableHandle />
{/* Main Chat Area */}
<ResizablePanel defaultSize={75} minSize={50}>
<div className="h-full flex flex-col">
{/* Header */}
<div className="h-14 border-b flex items-center px-4 justify-between bg-background/50 backdrop-blur gap-4">
<div className="flex items-center gap-2">
{isSidebarCollapsed && (
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8">
<PanelLeftOpen className="w-4 h-4" />
</Button>
)}
{!isSidebarCollapsed && (
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="h-8 w-8 text-muted-foreground">
<PanelLeftClose className="w-4 h-4" />
</Button>
)}
<div className="font-semibold">Chat</div>
</div>
<div className="flex-1 max-w-[300px]">
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Selecione um modelo..." />
</SelectTrigger>
<SelectContent>
{models.map(m => (
<SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto scroll-smooth">
{messages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-4">
<div className="p-4 rounded-full bg-muted/50">
<MessageSquare className="w-8 h-8" />
</div>
<p>Inicie uma conversa com {selectedModel}</p>
</div>
) : (
<div className="flex flex-col pb-4">
{messages.map((msg, i) => (
<ChatMessage key={i} message={msg} />
))}
{isLoading && (
<div className="px-6 py-4 text-xs text-muted-foreground animate-pulse">
Gerando resposta...
</div>
)}
</div>
)}
</div>
{/* Input */}
<ChatInput
onSend={handleSend}
onStop={stop}
isLoading={isLoading}
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Right Sidebar (Settings & Monitor) */}
<ResizablePanel defaultSize={25} minSize={20} maxSize={40} className="bg-muted/10">
<Tabs defaultValue="params" className="h-full flex flex-col">
<div className="border-b px-4">
<TabsList className="w-full justify-start h-12 bg-transparent p-0 gap-4">
<TabsTrigger
value="params"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none px-0"
>
Parâmetros
</TabsTrigger>
<TabsTrigger
value="system"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none px-0"
>
Sistema
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto m-0">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">System Prompt</label>
<PromptGeneratorDialog
defaultModel={selectedModel}
onPromptGenerated={setSystemPrompt}
/>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="min-h-[300px] resize-none font-mono text-sm"
placeholder="Defina como a IA deve se comportar..."
/>
<p className="text-xs text-muted-foreground">
Instruções globais para o comportamento do modelo.
</p>
</div>
</TabsContent>
<TabsContent value="system" className="flex-1 m-0 overflow-hidden">
<SystemPanel />
</TabsContent>
</Tabs>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+122
View File
@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "OllaHub",
description: "Interface moderna para Ollama",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</body>
</html>
);
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useOllamaCheck } from "@/hooks/use-ollama-check";
import { Hero } from "@/components/landing/hero";
import { InstallModal } from "@/components/modals/install-modal";
import { StoppedCard } from "@/components/landing/stopped-card";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
export default function Home() {
const { status, check } = useOllamaCheck();
const router = useRouter();
const handleStart = () => {
const setupComplete = localStorage.getItem("ollahub_setup_complete");
if (setupComplete) {
router.push("/chat");
} else {
router.push("/setup");
}
};
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>
);
}
return (
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
{/* Layout for Running State */}
{status === 'running' && (
<Hero onStart={handleStart} />
)}
{/* Layout for Stopped State */}
{status === '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} />
</div>
)}
{/* Layout for Not Installed State (Background + Modal) */}
{status === 'not_installed' && (
<>
<div className="opacity-20 pointer-events-none blur-sm">
<Hero onStart={() => {}} />
</div>
{/* Hero is shown in background, modal is open */}
<InstallModal
open={true}
onCheckAgain={check}
/>
</>
)}
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { useHardware } from "@/hooks/use-hardware";
import { HardwareScan } from "@/components/setup/hardware-scan";
import { ModelSelector } from "@/components/setup/model-selector";
import { getRecommendation } from "@/lib/recommendation";
import { useRouter } from "next/navigation";
export default function SetupPage() {
const { specs, loading } = useHardware();
const router = useRouter();
const handleComplete = () => {
// Save setup state if needed
localStorage.setItem("ollahub_setup_complete", "true");
router.push("/chat");
};
return (
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground">
<div className="w-full max-w-2xl space-y-8 animate-in fade-in duration-700">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold tracking-tighter">Configuração Inicial</h1>
<p className="text-muted-foreground">Vamos preparar o ambiente ideal para você.</p>
</div>
<HardwareScan specs={specs} loading={loading} />
{!loading && specs && (
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-300 fill-mode-backwards">
<ModelSelector
recommendation={getRecommendation(specs)}
onComplete={handleComplete}
/>
</div>
)}
</div>
</main>
);
}