feat: enhance UI and functionality across chat and settings

- Added custom scrollbar styles for improved aesthetics and usability across the interface.
- Implemented responsive adjustments for chat sidebar and input components to enhance mobile experience.
- Integrated tooltip components in select elements for better user guidance and information display.
- Updated chat input and message components for improved styling and functionality.
- Enhanced settings page with semantic query expansion options and improved model selection UI.
- Refactored sidebar list to include better date formatting and tooltip support for session titles.
- Introduced GPU stats monitoring in the system panel for better hardware insights.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-26 19:04:46 -03:00
1 parent 84ea2dec19
commit e5f062040c
33 files changed
+4670 -465

No files matched your search

+2 -2
View File
@@ -112,14 +112,14 @@ export function ChatInput({
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Pergunte qualquer coisa..."
className={`min-h-[56px] max-h-[200px] w-full resize-none bg-transparent py-4 pl-6 pr-32 text-base sm:text-lg rounded-[2rem] border-0 focus-visible:ring-0 shadow-none placeholder:text-muted-foreground/50 ${
className={`min-h-[52px] sm:min-h-[56px] max-h-[200px] w-full resize-none bg-transparent py-3 sm:py-4 pl-4 sm:pl-6 pr-28 sm:pr-32 text-[16px] sm:text-lg rounded-[2rem] border-0 focus-visible:ring-0 shadow-none placeholder:text-muted-foreground/50 ${
input.trim() && !isValid ? 'text-destructive' : ''
}`}
rows={1}
/>
{/* Right Actions */}
<div className="absolute right-3 bottom-2.5 flex items-center gap-1.5">
<div className="absolute right-2 sm:right-3 bottom-2 sm:bottom-2.5 flex items-center gap-1 sm:gap-1.5">
{onWebSearchToggle && (
<TooltipProvider>
<Tooltip delayDuration={300}>
+2
View File
@@ -123,3 +123,5 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
);
}
+253 -91
View File
@@ -3,10 +3,12 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Label } from "@/components/ui/label";
import { Download, Loader2, X } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Download, Loader2, X, Upload, Globe, AlertCircle, File } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
// Usar file dialog do Tauri v2
interface ModelDownloadDialogProps {
open: boolean;
@@ -24,6 +26,7 @@ interface DownloadInfo {
}
export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) {
const [activeTab, setActiveTab] = useState<"download" | "local">("download");
const [modelName, setModelName] = useState("");
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
@@ -33,6 +36,12 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
const [isInstalled, setIsInstalled] = useState(false);
const isDownloadingRef = useRef(false);
const modelNameRef = useRef("");
// Estados para instalação local
const [isInstallingLocal, setIsInstallingLocal] = useState(false);
const [localInstallProgress, setLocalInstallProgress] = useState(0);
const [localInstallError, setLocalInstallError] = useState<string | null>(null);
const [localModelName, setLocalModelName] = useState("");
// Atualizar refs quando estado muda
useEffect(() => {
@@ -43,6 +52,7 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
// Resetar estado quando dialog abre/fecha
useEffect(() => {
if (open) {
setActiveTab("download");
setModelName("");
setIsDownloading(false);
setDownloadProgress(0);
@@ -52,6 +62,11 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
setIsInstalled(false);
isDownloadingRef.current = false;
modelNameRef.current = "";
// Reset estados locais
setIsInstallingLocal(false);
setLocalInstallProgress(0);
setLocalInstallError(null);
setLocalModelName("");
}
}, [open]);
@@ -243,6 +258,69 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
}
};
const handleSelectFileButton = async () => {
try {
// Usar comando Rust para abrir dialog nativo do sistema
// Isso evita limitações do input file HTML5 com arquivos grandes
const selectedPath = await invoke<string | null>('open_gguf_file_dialog');
if (!selectedPath) {
// Usuário cancelou
return;
}
setIsInstallingLocal(true);
setLocalInstallError(null);
setLocalInstallProgress(0);
try {
setLocalInstallProgress(10);
// Extrair nome do arquivo do caminho
const pathParts = selectedPath.split(/[/\\]/);
const fileName = pathParts[pathParts.length - 1] || 'model';
const fileNameLower = fileName.toLowerCase();
const hasGgufExtension = fileNameLower.endsWith('.gguf');
// Extrair nome do modelo (remover extensão se for .gguf, senão manter)
const fileNameWithoutExt = hasGgufExtension
? fileName.replace(/\.gguf$/i, '')
: fileName;
const modelNameToUse = localModelName.trim() || fileNameWithoutExt;
setLocalInstallProgress(30);
// Instalar diretamente do caminho (sem precisar copiar para temp primeiro)
const installedModelName = await invoke<string>('install_gguf_model', {
filePath: selectedPath,
modelName: modelNameToUse || undefined,
});
setLocalInstallProgress(100);
await new Promise(resolve => setTimeout(resolve, 500));
if (onSuccess) {
onSuccess(installedModelName);
}
setTimeout(() => {
onOpenChange(false);
}, 1000);
} catch (err: any) {
const errorMsg = err?.message || 'Erro ao instalar modelo GGUF';
setLocalInstallError(errorMsg);
console.error('Erro ao instalar modelo GGUF:', err);
} finally {
setIsInstallingLocal(false);
}
} catch (err: any) {
console.error('Erro ao abrir dialog de arquivo:', err);
setLocalInstallError('Erro ao abrir seletor de arquivo');
}
};
const isValid = modelName.trim().length > 0 && !validateModelName(modelName.trim());
return (
@@ -251,114 +329,198 @@ export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDown
handleClose();
}
}}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Adicionar Modelo</DialogTitle>
<DialogDescription>
Digite o nome do modelo do Ollama que deseja baixar (ex: llama3.2:1b, mistral:latest)
Baixe modelos do Ollama ou instale arquivos GGUF locais
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="model-name">Nome do Modelo</Label>
<Input
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="ex: llama3.2:1b"
disabled={isDownloading}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValid && !isDownloading) {
handleDownload();
}
}}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{isInstalled && !isDownloading && (
<p className="text-sm text-green-600 dark:text-green-400">
✓ Este modelo já está instalado
</p>
)}
</div>
{isDownloading && (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "download" | "local")} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="download" className="flex items-center gap-2">
<Globe className="w-4 h-4" />
Baixar do Ollama
</TabsTrigger>
<TabsTrigger value="local" className="flex items-center gap-2">
<Upload className="w-4 h-4" />
Instalar Local
</TabsTrigger>
</TabsList>
<TabsContent value="download" className="space-y-4 mt-4">
<div className="space-y-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{downloadStatus || "Iniciando..."}</span>
<span>{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{downloadInfo.downloaded && downloadInfo.total
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
: downloadInfo.downloaded || ""}
</span>
{downloadInfo.speed && (
<span>{downloadInfo.speed}</span>
)}
</div>
<Label htmlFor="model-name">Nome do Modelo</Label>
<Input
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="ex: llama3.2:1b"
disabled={isDownloading}
onKeyDown={(e) => {
if (e.key === 'Enter' && isValid && !isDownloading) {
handleDownload();
}
}}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{isInstalled && !isDownloading && (
<p className="text-sm text-green-600 dark:text-green-400">
✓ Este modelo já está instalado
</p>
)}
</div>
)}
{isInstalled && !isDownloading && (
<div className="bg-muted/50 p-3 rounded-md text-sm">
<p className="text-muted-foreground">
O modelo está instalado e pronto para uso.
</p>
{isDownloading && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{downloadStatus || "Iniciando..."}</span>
<span>{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{downloadInfo.downloaded && downloadInfo.total
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
: downloadInfo.downloaded || ""}
</span>
{downloadInfo.speed && (
<span>{downloadInfo.speed}</span>
)}
</div>
)}
</div>
)}
{isInstalled && !isDownloading && (
<div className="bg-muted/50 p-3 rounded-md text-sm">
<p className="text-muted-foreground">
O modelo está instalado e pronto para uso.
</p>
</div>
)}
</TabsContent>
<TabsContent value="local" className="space-y-4 mt-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="local-model-name">Nome do Modelo (opcional)</Label>
<Input
id="local-model-name"
value={localModelName}
onChange={(e) => setLocalModelName(e.target.value)}
placeholder="Deixe vazio para usar o nome do arquivo"
disabled={isInstallingLocal}
/>
<p className="text-xs text-muted-foreground">
Se não especificar, o nome será extraído do arquivo
</p>
</div>
<div className="space-y-4">
{isInstallingLocal ? (
<div className="border rounded-xl p-6 bg-muted/30 space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
<div className="flex-1">
<p className="font-medium text-sm">Instalando modelo...</p>
<p className="text-xs text-muted-foreground mt-1">
Processando arquivo...
</p>
</div>
</div>
{localInstallProgress > 0 && (
<div className="space-y-2">
<Progress value={localInstallProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-center">
{localInstallProgress}%
</p>
</div>
)}
</div>
) : (
<>
<div className="border-2 border-dashed rounded-xl p-8 text-center bg-muted/30">
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-4">
Selecione um arquivo GGUF para instalar localmente
</p>
<Button
onClick={handleSelectFileButton}
disabled={isInstallingLocal}
className="w-full"
>
<File className="w-4 h-4 mr-2" />
Selecionar arquivo GGUF
</Button>
</div>
{localInstallError && (
<div className="border border-destructive/50 rounded-xl p-4 bg-destructive/5 space-y-2">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm text-destructive">Erro ao instalar modelo</p>
<p className="text-xs text-destructive/80 mt-1">{localInstallError}</p>
</div>
</div>
</div>
)}
</>
)}
</div>
<div className="bg-muted/30 border border-border/50 rounded-lg p-3 text-xs text-muted-foreground">
<p className="font-medium mb-1">ℹ️ Modelos instalados localmente</p>
<p>Funcionam sem conexão à internet. O arquivo será copiado para o diretório do Ollama.</p>
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button
variant="outline"
onClick={handleCancel}
disabled={isInstallingLocal}
>
{isDownloading ? (
<>
<X className="mr-2 h-4 w-4" />
Cancelar Download
</>
) : (
<>
<X className="mr-2 h-4 w-4" />
Cancelar
</>
)}
</Button>
<Button
onClick={() => {
if (isInstalled && !isDownloading) {
handleSuccess(modelName.trim());
onOpenChange(false);
} else {
handleDownload();
}
}}
disabled={!isValid || isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Baixando...
</>
) : isInstalled ? (
<>
Usar Modelo
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Baixar
</>
)}
<X className="mr-2 h-4 w-4" />
Cancelar
</Button>
{activeTab === "download" && (
<Button
onClick={() => {
if (isInstalled && !isDownloading) {
handleSuccess(modelName.trim());
onOpenChange(false);
} else {
handleDownload();
}
}}
disabled={!isValid || isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Baixando...
</>
) : isInstalled ? (
<>
Usar Modelo
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Baixar
</>
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
+49 -24
View File
@@ -2,6 +2,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Sparkles, Loader2 } from "lucide-react";
import { useState, useEffect } from "react";
import { usePromptGenerator } from "@/hooks/use-prompt-generator";
@@ -92,30 +93,54 @@ export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: Promp
<label htmlFor="model-select" className="text-sm font-medium">
Modelo para Geração
</label>
<Select
value={selectedModel || undefined}
onValueChange={setSelectedModel}
disabled={loading || models.length === 0}
>
<SelectTrigger id="model-select">
<SelectValue placeholder={
loading
? "Carregando modelos..."
: models.length === 0
? "Nenhum modelo disponível"
: "Selecione um modelo..."
} />
</SelectTrigger>
<SelectContent>
{models.length === 0 ? (
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
) : (
models.map(m => (
<SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>
))
)}
</SelectContent>
</Select>
<TooltipProvider>
<Select
value={selectedModel || undefined}
onValueChange={setSelectedModel}
disabled={loading || models.length === 0}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
<SelectValue placeholder={
loading
? "Carregando modelos..."
: models.length === 0
? "Nenhum modelo disponível"
: "Selecione um modelo..."
} className="truncate" />
</SelectTrigger>
</TooltipTrigger>
{selectedModel && (
<TooltipContent side="top" className="max-w-[70vw] break-words">
<p className="text-sm">{selectedModel}</p>
</TooltipContent>
)}
</Tooltip>
<SelectContent className="max-w-[90vw]">
{models.length === 0 ? (
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
) : (
models.map(m => (
<Tooltip key={m.name}>
<TooltipTrigger asChild>
<SelectItem value={m.name} className="min-w-0">
<span className="truncate block" style={{ maxWidth: '70vw' }}>
{m.name}
</span>
</SelectItem>
</TooltipTrigger>
{m.name.length > 40 && (
<TooltipContent side="right" className="max-w-[70vw] break-words">
<p className="text-sm">{m.name}</p>
</TooltipContent>
)}
</Tooltip>
))
)}
</SelectContent>
</Select>
</TooltipProvider>
<p className="text-xs text-muted-foreground">
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
</p>
+141 -54
View File
@@ -1,6 +1,7 @@
import { SessionSummary } from "@/hooks/use-chat-storage";
import { Button } from "@/components/ui/button";
import { Trash2, Plus } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Trash2, Plus, MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
interface SidebarListProps {
@@ -13,11 +14,36 @@ interface SidebarListProps {
function formatDate(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInHours = diffInMs / (1000 * 60 * 60);
const diffInDays = diffInMs / (1000 * 60 * 60 * 24);
// Se foi hoje, mostrar apenas hora
if (diffInHours < 24) {
return new Intl.DateTimeFormat('pt-BR', {
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
// Se foi ontem
if (diffInDays < 2) {
return 'Ontem';
}
// Se foi nos últimos 7 dias
if (diffInDays < 7) {
return new Intl.DateTimeFormat('pt-BR', {
weekday: 'short'
}).format(date);
}
// Caso contrário, mostrar data completa
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
year: diffInDays > 365 ? 'numeric' : undefined
}).format(date);
}
@@ -29,61 +55,122 @@ export function SidebarList({
onNewChat
}: SidebarListProps) {
return (
<div className="flex flex-col h-full border-r bg-muted/10">
<div className="p-4 border-b">
<Button onClick={onNewChat} className="w-full justify-start gap-2" variant="outline">
<Plus className="w-4 h-4" />
Nova Conversa
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-2 space-y-2">
{sessions.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
Nenhuma conversa salva
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
className={cn(
"group flex items-center justify-between p-2 rounded-lg cursor-pointer hover:bg-accent transition-colors",
currentSessionId === session.id && "bg-accent"
)}
onClick={() => onSelectSession(session.id)}
>
<div className="flex items-start gap-3 overflow-hidden flex-1">
<span className="text-lg shrink-0 mt-0.5" role="img" aria-label="emoji">
{session.emoji || '💬'}
</span>
<div className="flex flex-col overflow-hidden flex-1">
<span className="text-sm font-medium truncate" title={session.title}>
{session.title}
</span>
<span className="text-xs text-muted-foreground truncate">
{formatDate(session.updated_at)}
</span>
</div>
<TooltipProvider delayDuration={300}>
<div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar min-w-[240px] max-w-[360px] w-full">
{/* Header com botão Nova Conversa */}
<div className="p-3 sm:p-4 border-b border-sidebar-border flex-shrink-0">
<Button
onClick={onNewChat}
className="w-full justify-start gap-2 text-sm font-medium h-9 sm:h-10"
variant="outline"
>
<Plus className="w-4 h-4 shrink-0" />
<span className="truncate">Nova Conversa</span>
</Button>
</div>
{/* Lista de chats com scroll customizado */}
<div className="flex-1 overflow-y-auto min-h-0 sidebar-chat-list">
<div className="p-3 sm:p-4 space-y-2 sm:space-y-2.5">
{sessions.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
Nenhuma conversa salva
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Comece uma nova conversa para começar
</p>
</div>
)}
{sessions.map((session) => {
const isSelected = currentSessionId === session.id;
const showTooltip = session.title.length > 30;
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.id);
}}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
))}
return (
<Tooltip key={session.id} delayDuration={300} disableHoverableContent>
<TooltipTrigger asChild>
<div
className={cn(
"group relative bg-card border rounded-lg cursor-pointer transition-all duration-200",
"hover:shadow-md hover:border-primary/20 hover:-translate-y-0.5",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isSelected && "border-primary/40 shadow-sm bg-accent/50",
"active:scale-[0.98]"
)}
onClick={() => onSelectSession(session.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectSession(session.id);
}
}}
>
{/* Conteúdo do card */}
<div className="p-3 sm:p-3.5 flex items-start gap-3 overflow-hidden">
{/* Ícone/Emoji */}
<div className="shrink-0 flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-md bg-muted/50 text-base sm:text-lg">
{session.emoji || '💬'}
</div>
{/* Texto do card */}
<div className="flex-1 min-w-0 overflow-hidden">
<h3
className="text-sm sm:text-base font-medium text-card-foreground truncate leading-tight mb-1"
>
{session.title}
</h3>
<p className="text-xs text-muted-foreground">
{formatDate(session.updated_at)}
</p>
</div>
{/* Botão de deletar - aparece no hover */}
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 sm:h-8 sm:w-8 shrink-0 transition-all duration-200",
"opacity-0 group-hover:opacity-100",
"hover:bg-destructive/10 hover:text-destructive",
"active:scale-95"
)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onDeleteSession(session.id);
}}
aria-label={`Deletar conversa "${session.title}"`}
>
<Trash2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</Button>
</div>
{/* Indicador de seleção */}
{isSelected && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary rounded-l-lg" />
)}
</div>
</TooltipTrigger>
{showTooltip && (
<TooltipContent side="right" className="max-w-xs">
<p className="break-words font-medium">{session.title}</p>
<p className="text-xs text-muted-foreground mt-1">
{formatDate(session.updated_at)}
</p>
</TooltipContent>
)}
</Tooltip>
);
})}
</div>
</div>
</div>
</div>
</TooltipProvider>
);
}
+94 -28
View File
@@ -1,13 +1,18 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Trash2, HardDrive, Cpu, Database } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Trash2, HardDrive, Cpu, Database, Upload, Globe } from "lucide-react";
import { useSystemMonitor } from "@/hooks/use-system-monitor";
import { useLocalModels } from "@/hooks/use-local-models";
import { useState } from "react";
import { ModelDownloadDialog } from "./model-download-dialog";
export function SystemPanel() {
const stats = useSystemMonitor();
const { models, deleteModel, refresh } = useLocalModels();
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
const ramUsagePercent = stats
? (stats.memory_used / stats.memory_total) * 100
@@ -63,42 +68,103 @@ export function SystemPanel() {
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Modelos Instalados
</h3>
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
Atualizar
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDownloadDialog(true)}
className="h-6 text-xs"
>
<Upload className="w-3 h-3 mr-1" />
Adicionar
</Button>
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
Atualizar
</Button>
</div>
</div>
<div className="space-y-2">
{models.map((model) => (
<Card key={model.id} className="overflow-hidden">
<CardContent className="p-3 flex items-center justify-between">
<div className="min-w-0">
<div className="font-medium text-sm truncate" title={model.name}>
{model.name}
{models.map((model) => {
// Tentar detectar se é modelo local/GGUF (heurística: modelos GGUF geralmente têm nomes específicos)
// Por enquanto, vamos mostrar todos como disponíveis
const isLocal = model.name.toLowerCase().includes('gguf') ||
model.name.toLowerCase().endsWith('.gguf');
return (
<Card key={model.id} className="overflow-hidden hover:bg-muted/50 transition-colors">
<CardContent className="p-3 flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<div className="font-medium text-sm truncate" title={model.name}>
{model.name}
</div>
{isLocal && (
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
<Upload className="w-2.5 h-2.5 mr-1" />
Local
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<Database className="w-3 h-3" />
{model.size}
</div>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<Database className="w-3 h-3" />
{model.size}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => deleteModel(model.name)}
>
<Trash2 className="w-4 h-4" />
</Button>
</CardContent>
</Card>
))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 flex-shrink-0"
onClick={async () => {
if (confirm(`Tem certeza que deseja remover o modelo "${model.name}"?`)) {
try {
await deleteModel(model.name);
} catch (error) {
console.error('Erro ao remover modelo:', error);
}
}
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Remover modelo</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardContent>
</Card>
);
})}
{models.length === 0 && (
<div className="text-center text-sm text-muted-foreground py-4">
Nenhum modelo encontrado.
<div className="text-center text-sm text-muted-foreground py-8 space-y-2">
<p>Nenhum modelo encontrado.</p>
<Button
variant="outline"
size="sm"
onClick={() => setShowDownloadDialog(true)}
className="mt-2"
>
<Upload className="w-4 h-4 mr-2" />
Instalar Modelo
</Button>
</div>
)}
</div>
</div>
<ModelDownloadDialog
open={showDownloadDialog}
onOpenChange={setShowDownloadDialog}
onSuccess={(modelName) => {
refresh();
setShowDownloadDialog(false);
}}
/>
</div>
);
}
@@ -65,3 +65,5 @@ export function WelcomeNotification() {
+271
View File
@@ -0,0 +1,271 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Monitor, Thermometer, Zap, Fan, Cpu, HardDrive, Activity } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface GpuStats {
id: string;
name: string;
vendor: string | null;
vram_used_mb: number | null;
vram_total_mb: number | null;
vram_percent: number | null;
compute_usage_percent: number | null;
graphics_usage_percent: number | null;
overall_usage_percent: number | null;
temperature_celsius: number | null;
temperature_max_celsius: number | null;
power_watts: number | null;
power_max_watts: number | null;
fan_speed_rpm: number | null;
fan_speed_percent: number | null;
processes_count: number | null;
driver_version: string | null;
api: string | null;
}
interface GpuDashboardProps {
gpuId?: string;
pollInterval?: number; // em milissegundos, padrão 2000ms
}
export function GpuDashboard({ gpuId, pollInterval = 2000 }: GpuDashboardProps) {
const [stats, setStats] = useState<GpuStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadStats = async () => {
try {
const data = await invoke<GpuStats | null>('get_gpu_stats', { gpuId: gpuId || null });
setStats(data);
setError(null);
setIsLoading(false);
} catch (err) {
console.error('Failed to load GPU stats:', err);
setError(err instanceof Error ? err.message : 'Erro ao carregar estatísticas da GPU');
setIsLoading(false);
}
};
loadStats();
const interval = setInterval(loadStats, pollInterval);
return () => clearInterval(interval);
}, [gpuId, pollInterval]);
if (isLoading) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Carregando estatísticas da GPU...
</CardContent>
</Card>
);
}
if (error || !stats) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{error || 'GPU não detectada ou sem suporte para monitoramento detalhado'}
</CardContent>
</Card>
);
}
const formatBytes = (mb: number | null): string => {
if (!mb) return '-';
if (mb < 1024) return `${mb} MB`;
return `${(mb / 1024).toFixed(2)} GB`;
};
const getStatusColor = (value: number | null, thresholds: { good: number; warning: number }): string => {
if (!value) return 'bg-muted';
if (value < thresholds.good) return 'bg-green-500';
if (value < thresholds.warning) return 'bg-yellow-500';
return 'bg-red-500';
};
const getTemperatureColor = (temp: number | null): string => {
if (!temp) return 'text-muted-foreground';
if (temp < 60) return 'text-green-500';
if (temp < 80) return 'text-yellow-500';
return 'text-red-500';
};
return (
<div className="space-y-4">
{/* Card Principal da GPU */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Monitor className="w-4 h-4" />
GPU
</CardTitle>
<div className="flex items-center gap-2">
{stats.vendor && (
<Badge variant="outline" className="text-xs">
{stats.vendor}
</Badge>
)}
{stats.api && (
<Badge variant="secondary" className="text-xs">
{stats.api}
</Badge>
)}
</div>
</div>
<p className="text-xs text-muted-foreground mt-1 truncate" title={stats.name}>
{stats.name}
</p>
{stats.driver_version && (
<p className="text-xs text-muted-foreground">Driver: {stats.driver_version}</p>
)}
</CardHeader>
<CardContent className="space-y-4">
{/* VRAM */}
{stats.vram_total_mb && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<HardDrive className="w-3 h-3" />
Memória VRAM
</span>
<span className="font-medium">
{formatBytes(stats.vram_used_mb)} / {formatBytes(stats.vram_total_mb)}
{stats.vram_percent && ` (${stats.vram_percent.toFixed(1)}%)`}
</span>
</div>
{stats.vram_percent !== null && (
<Progress
value={stats.vram_percent}
className="h-2"
/>
)}
</div>
)}
{/* Uso de Processamento */}
{stats.overall_usage_percent !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5">
<Cpu className="w-3 h-3" />
Uso de Processamento
</span>
<span className="font-medium">{stats.overall_usage_percent.toFixed(1)}%</span>
</div>
<Progress
value={stats.overall_usage_percent}
className="h-2"
/>
{stats.compute_usage_percent !== null && stats.graphics_usage_percent !== null && (
<div className="flex gap-2 text-[10px] text-muted-foreground">
<span>Compute: {stats.compute_usage_percent.toFixed(1)}%</span>
<span>Graphics: {stats.graphics_usage_percent.toFixed(1)}%</span>
</div>
)}
</div>
)}
{/* Grid de Métricas */}
<div className="grid grid-cols-2 gap-3 pt-2">
{/* Temperatura */}
{stats.temperature_celsius !== null && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Thermometer className="w-3 h-3" />
Temperatura
</div>
<div className="flex items-baseline gap-1">
<span className={`text-lg font-semibold ${getTemperatureColor(stats.temperature_celsius)}`}>
{stats.temperature_celsius.toFixed(0)}
</span>
<span className="text-xs text-muted-foreground">°C</span>
{stats.temperature_max_celsius && (
<span className="text-[10px] text-muted-foreground">
/ {stats.temperature_max_celsius.toFixed(0)}°C
</span>
)}
</div>
</div>
)}
{/* Energia */}
{stats.power_watts !== null && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Zap className="w-3 h-3" />
Energia
</div>
<div className="flex items-baseline gap-1">
<span className="text-lg font-semibold">
{stats.power_watts.toFixed(1)}
</span>
<span className="text-xs text-muted-foreground">W</span>
{stats.power_max_watts && (
<span className="text-[10px] text-muted-foreground">
/ {stats.power_max_watts.toFixed(1)}W
</span>
)}
</div>
</div>
)}
{/* Ventilador */}
{(stats.fan_speed_percent !== null || stats.fan_speed_rpm !== null) && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Fan className="w-3 h-3" />
Ventilador
</div>
<div className="flex items-baseline gap-1">
{stats.fan_speed_percent !== null && (
<>
<span className="text-lg font-semibold">
{stats.fan_speed_percent.toFixed(0)}
</span>
<span className="text-xs text-muted-foreground">%</span>
</>
)}
{stats.fan_speed_rpm !== null && (
<span className="text-xs text-muted-foreground ml-1">
({stats.fan_speed_rpm} RPM)
</span>
)}
</div>
</div>
)}
{/* Processos */}
{stats.processes_count !== null && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Activity className="w-3 h-3" />
Processos
</div>
<div className="flex items-baseline gap-1">
<span className="text-lg font-semibold">
{stats.processes_count}
</span>
<span className="text-xs text-muted-foreground">
{stats.processes_count === 1 ? 'processo' : 'processos'}
</span>
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+7 -20
View File
@@ -6,6 +6,7 @@ import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import { Cpu, MemoryStick, HardDrive, Activity, Clock, Monitor } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { GpuDashboard } from './GpuDashboard';
interface SystemStats {
cpu_usage: number;
@@ -85,7 +86,8 @@ export function HardwareDashboard() {
};
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* CPU Card */}
<Card>
<CardHeader className="pb-3">
@@ -135,25 +137,6 @@ export function HardwareDashboard() {
</CardContent>
</Card>
{/* GPU Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Monitor className="w-4 h-4" />
GPU
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm">
{stats.gpu_name ? (
<span className="text-muted-foreground">{stats.gpu_name}</span>
) : (
<span className="text-muted-foreground italic">Não detectada</span>
)}
</div>
</CardContent>
</Card>
{/* System Info Card */}
<Card>
<CardHeader className="pb-3">
@@ -207,6 +190,10 @@ export function HardwareDashboard() {
</div>
</CardContent>
</Card>
</div>
{/* GPU Dashboard - Substitui o card simples */}
<GpuDashboard pollInterval={2000} />
</div>
);
}
+1 -1
View File
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 [&>span]:truncate [&>span]:min-w-0",
className
)}
{...props}