feat(ui): enhance chat UI with new components and styling
refactor(chat): improve message display and actions feat(chat): add high contrast mode toggle style(chat): update spacing and sizing for better readability feat(chat): add new Section and Stack layout components docs: add UI style guide documentation fix(chat): improve browser connection handling in scraper feat(settings): expand default sources and improve UI refactor: consolidate chat page layout and components
This commit is contained in:
1 parent
c06e7ae5cf
commit
4006a72eec
20 files changed
+741
-235
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Send, Square, Globe, WifiOff } from "lucide-react";
|
||||
import { Send, Square, Globe, WifiOff, ChevronUp } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -15,6 +15,8 @@ interface ChatInputProps {
|
||||
isLoading: boolean;
|
||||
webSearchEnabled?: boolean;
|
||||
onWebSearchToggle?: (enabled: boolean) => void;
|
||||
categories?: SearchCategory[];
|
||||
onToggleCategory?: (id: string, enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -23,9 +25,12 @@ export function ChatInput({
|
||||
isLoading,
|
||||
webSearchEnabled = true,
|
||||
onWebSearchToggle,
|
||||
categories = [],
|
||||
onToggleCategory,
|
||||
}: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [webDialogOpen, setWebDialogOpen] = useState(false);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -49,8 +54,8 @@ export function ChatInput({
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<div className="p-4 border-t bg-background">
|
||||
<div className="relative flex items-end gap-2 max-w-4xl mx-auto w-full">
|
||||
<div className="p-6 bg-transparent">
|
||||
<div className="relative max-w-3xl mx-auto w-full">
|
||||
<div className="flex-1 relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
@@ -58,7 +63,7 @@ export function ChatInput({
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua mensagem..."
|
||||
className="min-h-[50px] max-h-[200px] resize-none py-3 pr-12"
|
||||
className="min-h-[48px] resize-none overflow-hidden py-4 pr-12 rounded-xl border shadow-lg"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 flex items-center gap-2">
|
||||
@@ -71,16 +76,9 @@ export function ChatInput({
|
||||
variant={webSearchEnabled ? "default" : "ghost"}
|
||||
onClick={() => {
|
||||
if (!webSearchEnabled) {
|
||||
// Ao ativar, não precisa de confirmação
|
||||
onWebSearchToggle(true);
|
||||
} else {
|
||||
// Ao desativar, mostrar aviso
|
||||
const confirmed = window.confirm(
|
||||
"Atenção: Desativar a pesquisa web pode reduzir a qualidade das respostas para fatos recentes, especialmente em modelos menores. Deseja continuar?"
|
||||
);
|
||||
if (confirmed) {
|
||||
onWebSearchToggle(false);
|
||||
}
|
||||
setWebDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
className="h-8 w-8"
|
||||
@@ -103,6 +101,28 @@ export function ChatInput({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{onToggleCategory && categories && categories.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="outline" className="h-8 w-8" title="Gerenciar fontes">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuLabel>Fontes de conhecimento</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{categories.map((cat) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={cat.id}
|
||||
checked={cat.enabled}
|
||||
onCheckedChange={(checked) => onToggleCategory(cat.id, !!checked)}
|
||||
>
|
||||
{cat.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
@@ -120,6 +140,26 @@ export function ChatInput({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={webDialogOpen} onOpenChange={setWebDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Desativar pesquisa web?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Desativar a busca pode reduzir a qualidade em perguntas sobre fatos recentes, preços, notícias e conteúdo em rápida mudança.
|
||||
Use com cautela — modelos locais sem contexto web tendem a alucinar mais nesses cenários.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWebDialogOpen(false)}>Manter ativado</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => { onWebSearchToggle?.(false); setWebDialogOpen(false); }}
|
||||
>
|
||||
Desativar pesquisa web
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="text-xs text-center text-muted-foreground mt-2">
|
||||
OllaHub pode cometer erros. Verifique informações importantes.
|
||||
</div>
|
||||
@@ -128,4 +168,23 @@ export function ChatInput({
|
||||
}
|
||||
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { SearchCategory } from "@/store/settings-store";
|
||||
|
||||
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useState } from 'react';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Message } from "@/hooks/use-chat";
|
||||
import { Bot, User } from "lucide-react";
|
||||
import { markdownRenderers } from "./markdown-renderers";
|
||||
import { MessageActions } from "./message-actions";
|
||||
import { MessageBackground } from "./message-background";
|
||||
|
||||
export function ChatMessage({ message }: { message: Message }) {
|
||||
const isUser = message.role === 'user';
|
||||
const [selected, setSelected] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"group flex w-full gap-4 p-6 relative",
|
||||
isUser ? "bg-background" : "bg-muted/30"
|
||||
)}>
|
||||
<MessageBackground
|
||||
role={isUser ? 'user' : 'assistant'}
|
||||
state={selected ? 'selected' : 'normal'}
|
||||
className={cn("cursor-pointer")}
|
||||
onClick={() => setSelected((v) => !v)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-sm flex items-center justify-center",
|
||||
"w-8 h-8 rounded-md flex items-center justify-center",
|
||||
isUser ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground"
|
||||
)}>
|
||||
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden min-w-0">
|
||||
<div className="prose dark:prose-invert max-w-none break-words">
|
||||
<div className="prose dark:prose-invert max-w-none break-words leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownRenderers}
|
||||
@@ -55,13 +61,11 @@ export function ChatMessage({ message }: { message: Message }) {
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Toolbar - Absolute positioned or flex end */}
|
||||
{!isUser && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<MessageActions content={message.content} role={message.role} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MessageBackground>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Copy, FileJson, FileText, MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Copy, FileJson, FileText, MoreHorizontal, Contrast } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string;
|
||||
@@ -15,6 +15,16 @@ interface MessageActionsProps {
|
||||
|
||||
export function MessageActions({ content, role }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [highContrast, setHighContrast] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = typeof window !== 'undefined' ? localStorage.getItem('highContrast') === 'true' : false;
|
||||
setHighContrast(saved);
|
||||
if (typeof document !== 'undefined') {
|
||||
if (saved) document.documentElement.setAttribute('data-high-contrast', 'true');
|
||||
else document.documentElement.removeAttribute('data-high-contrast');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
@@ -73,6 +83,22 @@ export function MessageActions({ content, role }: MessageActionsProps) {
|
||||
<FileJson className="mr-2 h-4 w-4" />
|
||||
<span>Copiar JSON</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const next = !highContrast;
|
||||
setHighContrast(next);
|
||||
if (typeof document !== 'undefined') {
|
||||
if (next) document.documentElement.setAttribute('data-high-contrast', 'true');
|
||||
else document.documentElement.removeAttribute('data-high-contrast');
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('highContrast', String(next));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Contrast className="mr-2 h-4 w-4" />
|
||||
<span>{highContrast ? 'Desativar Alto Contraste' : 'Ativar Alto Contraste'}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MessageRole = "user" | "assistant";
|
||||
type MessageVisualState = "normal" | "highlighted" | "selected";
|
||||
|
||||
interface MessageBackgroundProps extends React.ComponentProps<"div"> {
|
||||
role: MessageRole;
|
||||
state?: MessageVisualState;
|
||||
highContrast?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBackground({
|
||||
role,
|
||||
state = "normal",
|
||||
highContrast = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageBackgroundProps) {
|
||||
const base = "group flex w-full gap-3 md:gap-4 relative transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
|
||||
const roleCl = role === "user"
|
||||
? "rounded-2xl bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 px-4 py-3"
|
||||
: "bg-transparent px-0 py-0";
|
||||
const stateCl = state === "selected" ? "ring-2 ring-primary/40" : state === "highlighted" ? "ring-1 ring-accent/30" : "";
|
||||
const hc = highContrast ? "hc:bg-message-high-contrast hc:text-foreground" : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-selected={state === "selected"}
|
||||
className={cn(base, roleCl, stateCl, hc, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: Promp
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
placeholder="Ex: Quero um assistente especialista em Python que explique conceitos complexos de forma simples..."
|
||||
className="min-h-[150px]"
|
||||
className="min-h-[160px]"
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Search, BookOpen, CheckCircle2, AlertCircle, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ScrapedContent } from '@/services/webSearch';
|
||||
import { SearchStatus } from '@/hooks/use-web-search';
|
||||
@@ -73,24 +72,23 @@ export function SearchProgress({
|
||||
const showExpandButton = status === 'completed' && sources.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
"mx-4 mb-2 border border-border/50 bg-muted/30 transition-all",
|
||||
status === 'completed' && "opacity-60 hover:opacity-100",
|
||||
status === 'error' && "border-destructive/30 bg-destructive/5"
|
||||
<div className={cn(
|
||||
"mx-6 mb-2 transition-all",
|
||||
status === 'completed' && "opacity-70 hover:opacity-100",
|
||||
status === 'error' && "text-destructive"
|
||||
)}>
|
||||
<CardContent className="p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
{isActive ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className="w-3 h-3">
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<div className="p-2 pl-3 border-l-2 border-muted flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
{isActive ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div className="w-3 h-3">
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground truncate">
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -100,7 +98,7 @@ export function SearchProgress({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
className="h-7 w-7 p-0 flex-shrink-0"
|
||||
className="h-8 w-8 p-0 flex-shrink-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
@@ -109,15 +107,15 @@ export function SearchProgress({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de fontes expandida */}
|
||||
{isExpanded && sources.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t space-y-2">
|
||||
<div className="mt-2 ml-6 pl-3 border-l space-y-2">
|
||||
{sources.map((source, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2 p-2 rounded-md bg-background/50 hover:bg-background/80 transition-colors"
|
||||
className="flex items-start gap-2 p-2 rounded-md bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={getFaviconUrl(source.url)}
|
||||
@@ -145,8 +143,7 @@ export function SearchProgress({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ export function ThinkingIndicator({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 py-3 border-b border-muted/50 bg-muted/20">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="px-6 py-4 border-b border-muted/50 bg-muted/20">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
|
||||
const isActive = idx === currentStepIndex;
|
||||
const isPast = idx < currentStepIndex;
|
||||
|
||||
@@ -74,7 +74,7 @@ export function WebSearchStatus({
|
||||
status === 'error' && "border-l-red-500",
|
||||
(status === 'searching' || status === 'scraping') && "border-l-blue-500"
|
||||
)}>
|
||||
<CardContent className="p-3">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
|
||||
Reference in new issue
Block a user