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:
1 parent
84ea2dec19
commit
e5f062040c
33 files changed
+4670
-465
No files matched your search
@@ -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}>
|
||||
|
||||
@@ -123,3 +123,5 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user