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:
Evandro Fonseca Junior committed 2025-11-20 15:13:54 -03:00
1 parent c06e7ae5cf
commit 4006a72eec
20 files changed
+741 -235

No files matched your search

+64 -87
View File
@@ -1,17 +1,14 @@
'use client';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { MessageSquare, Settings, Server, Moon, Sun, PanelLeftClose, PanelLeftOpen, Loader2, ChevronDown } from "lucide-react";
import { ChatInput } from "@/components/chat/chat-input";
import { ChatMessage } from "@/components/chat/chat-message";
import { SystemPanel } from "@/components/chat/system-panel";
import { PromptGeneratorDialog } from "@/components/chat/prompt-generator";
import { SidebarList } from "@/components/chat/sidebar-list";
import { useChat } from "@/hooks/use-chat";
import { useLocalModels } from "@/hooks/use-local-models";
@@ -36,7 +33,7 @@ import defaultFormatPrompt from "@/data/prompts/default-format.md";
export default function ChatPage() {
const router = useRouter();
const { messages, setMessages, sendMessage, isLoading, stop, clearChat } = useChat();
const { models } = useLocalModels();
const { models, refresh } = useLocalModels();
const { theme, setTheme } = useTheme();
const {
@@ -56,6 +53,8 @@ export default function ChatPage() {
const settings = useSettingsStore();
const [selectedModel, setSelectedModel] = useState("");
const [mounted, setMounted] = useState(false);
const initializedRef = useRef(false);
// Initialize with default format prompt
const [systemPrompt, setSystemPrompt] = useState(defaultFormatPrompt || "Você é um assistente útil e prestativo.");
const [isChatsSidebarCollapsed, setIsChatsSidebarCollapsed] = useState(false);
@@ -71,10 +70,22 @@ export default function ChatPage() {
// Auto-select first model
useEffect(() => {
if (models.length > 0 && !selectedModel) {
setSelectedModel(models[0].name);
setMounted(true);
}, []);
useEffect(() => {
if (initializedRef.current) return;
const stored = settings.selectedModel;
const first = models.length > 0 ? models[0].name : "";
const next = stored || first || "";
if (next) {
setSelectedModel(next);
if (!stored) {
settings.setSelectedModel(next);
}
}
}, [models, selectedModel]);
initializedRef.current = true;
}, [settings.selectedModel, models]);
// Auto-scroll para o final durante streaming
useEffect(() => {
@@ -372,7 +383,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="rounded-lg text-muted-foreground hover:text-foreground"
>
{theme === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{mounted ? (theme === "dark" ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />) : null}
</Button>
<Button
variant="ghost"
@@ -436,17 +447,27 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div>
</div>
<div className="flex-1 max-w-[300px]">
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Selecione um modelo..." />
</SelectTrigger>
<SelectContent>
{models.map(m => (
<SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1 max-w-[420px] flex items-center gap-2">
<div className="flex-1">
<Select value={selectedModel} onValueChange={(v) => { setSelectedModel(v); settings.setSelectedModel(v); }}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Selecione um modelo..." />
</SelectTrigger>
<SelectContent>
{models.map(m => (
<SelectItem key={m.name} value={m.name}>
<span className="flex items-center justify-between w-full">
<span className="truncate">{m.name}</span>
<span className="ml-3 text-xs text-muted-foreground">{m.size}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={refresh} title="Atualizar modelos">
<Loader2 className="w-4 h-4" />
</Button>
</div>
</div>
@@ -460,7 +481,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
<p>Inicie uma conversa com {selectedModel}</p>
</div>
) : (
<div className="flex flex-col pb-4 max-w-4xl mx-auto w-full">
<div className="flex flex-col pb-4 max-w-3xl mx-auto w-full space-y-6">
{messages.map((msg, i) => {
// Verificar se a mensagem anterior teve busca web
const hasWebSources = i > 0 &&
@@ -471,6 +492,19 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
return (
<div key={i}>
<ChatMessage message={msg} />
{(msg.role === 'user') && (
(webSearch.status === 'searching' ||
webSearch.status === 'scraping' ||
webSearch.status === 'completed' ||
webSearch.status === 'error') && (
<SearchProgress
status={webSearch.status}
query={webSearch.currentQuery}
sources={webSearch.scrapedSources}
error={webSearch.error}
/>
)
)}
{/* Mostrar fontes após resposta do assistente que usou web search */}
{msg.role === 'assistant' && hasWebSources && (
<div className="px-6 pb-4">
@@ -507,18 +541,7 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div>
);
})}
{/* Web Search Progress - mostrar antes da resposta da IA */}
{(webSearch.status === 'searching' ||
webSearch.status === 'scraping' ||
webSearch.status === 'completed' ||
webSearch.status === 'error') && (
<SearchProgress
status={webSearch.status}
query={webSearch.currentQuery}
sources={webSearch.scrapedSources}
error={webSearch.error}
/>
)}
{isLoading && thinkingStep === 'complete' && (
<div className="px-6 py-4 text-xs text-muted-foreground animate-pulse">
Gerando resposta...
@@ -530,8 +553,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
<div ref={messagesEndRef} className="h-1" />
</div>
{/* Thinking Indicator */}
{thinkingStep && thinkingStep !== 'complete' && (
{/* Thinking Indicator - aparece apenas quando não há web search ativo */}
{thinkingStep && thinkingStep !== 'complete' && (webSearch.status === 'idle') && (
<ThinkingIndicator
currentStep={thinkingStep}
searchQuery={webSearch.currentQuery}
@@ -593,62 +616,16 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
isLoading={isLoading}
webSearchEnabled={webSearch.isEnabled}
onWebSearchToggle={webSearch.setEnabled}
categories={settings.webSearch.categories}
onToggleCategory={(id, enabled) => {
const cat = settings.webSearch.categories.find(c => c.id === id);
if (!cat) return;
settings.updateCategory({ ...cat, enabled });
}}
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* Right Sidebar (Settings & Monitor) */}
<ResizablePanel defaultSize={20} minSize={15} maxSize={30} className="bg-muted/10">
<Tabs defaultValue="params" className="h-full flex flex-col overflow-hidden">
<div className="border-b px-4">
<TabsList className="w-full justify-start h-12 bg-transparent p-0 gap-4">
<TabsTrigger
value="params"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none px-0"
>
Parâmetros
</TabsTrigger>
<TabsTrigger
value="system"
className="data-[state=active]:bg-transparent data-[state=active]:shadow-none data-[state=active]:border-b-2 data-[state=active]:border-primary rounded-none px-0"
>
Sistema
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="params" className="flex-1 p-4 space-y-6 overflow-y-auto overflow-x-hidden m-0">
<div className="space-y-6">
{/* System Prompt */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">System Prompt</label>
<PromptGeneratorDialog
defaultModel={selectedModel}
onPromptGenerated={setSystemPrompt}
/>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
className="min-h-[300px] resize-none font-mono text-sm"
placeholder="Defina como a IA deve se comportar..."
/>
<p className="text-xs text-muted-foreground">
Instruções globais para o comportamento do modelo.
</p>
</div>
</div>
</TabsContent>
<TabsContent value="system" className="flex-1 m-0 overflow-y-auto overflow-x-hidden">
<SystemPanel />
</TabsContent>
</Tabs>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
+32
View File
@@ -2,6 +2,7 @@
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@custom-variant hc (&:is([data-high-contrast=true] *));
@theme inline {
--color-background: var(--background);
@@ -37,10 +38,23 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-message-user: var(--message-user);
--color-message-assistant: var(--message-assistant);
--color-message-highlight: var(--message-highlight);
--color-message-selected: var(--message-selected);
--color-message-high-contrast: var(--message-high-contrast);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--space-1: 8px;
--space-2: 16px;
--space-3: 24px;
--space-4: 32px;
--space-5: 40px;
--space-6: 48px;
--space-7: 56px;
--space-8: 64px;
}
:root {
@@ -76,6 +90,11 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--message-user: oklch(0.965 0.02 245);
--message-assistant: oklch(0.972 0.012 300);
--message-highlight: oklch(0.94 0.02 250);
--message-selected: oklch(0.92 0.02 285);
--message-high-contrast: oklch(0.99 0 0);
}
.dark {
@@ -110,6 +129,19 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--message-user: oklch(0.23 0.02 245);
--message-assistant: oklch(0.24 0.015 300);
--message-highlight: oklch(0.34 0.02 250);
--message-selected: oklch(0.25 0.02 285);
--message-high-contrast: oklch(0.18 0.005 286);
}
:root[data-high-contrast="true"] {
--message-user: oklch(0.9 0 0);
--message-assistant: oklch(0.9 0 0);
--message-highlight: oklch(0.8 0 0);
--message-selected: oklch(0.7 0 0);
--message-high-contrast: oklch(0.99 0 0);
}
@layer base {
+70 -41
View File
@@ -425,19 +425,36 @@ export default function SettingsPage() {
<div className="space-y-2">
<Label htmlFor="max-results">Máximo de Resultados</Label>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<div className="flex items-center gap-3">
<Input
id="max-results"
type="number"
value={settings.webSearch.maxResults}
onChange={(e) => {
const v = parseInt(e.target.value || '0', 10);
const clamped = Math.min(100, Math.max(1, isNaN(v) ? 1 : v));
settings.setWebSearchMaxResults(clamped);
}}
className="w-24"
min={1}
max={100}
disabled={!settings.webSearch.enabled}
/>
<div className="flex-1">
<Slider
value={[settings.webSearch.maxResults]}
onValueChange={([value]) => settings.setWebSearchMaxResults(value)}
min={1}
max={100}
step={1}
disabled={!settings.webSearch.enabled}
/>
</div>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1</span>
<span className="font-medium">{settings.webSearch.maxResults}</span>
<span>10</span>
<span>100</span>
</div>
<Slider
value={[settings.webSearch.maxResults]}
onValueChange={([value]) => settings.setWebSearchMaxResults(value)}
min={1}
max={10}
step={1}
disabled={!settings.webSearch.enabled}
/>
</div>
</div>
@@ -516,9 +533,9 @@ export default function SettingsPage() {
variant="outline"
size="sm"
onClick={async () => {
if (confirm('Restaurar categorias padrão? Isso substituirá a configuração atual.')) {
if (confirm('Mesclar categorias padrão curadas com sua configuração (sem duplicatas)?')) {
try {
const defaultConfig: SourcesConfig = {
const curated: SourcesConfig = {
version: 1,
last_updated: new Date().toISOString(),
categories: [
@@ -526,11 +543,10 @@ export default function SettingsPage() {
id: 'academico',
name: 'Acadêmico',
base_sites: [
'scholar.google.com',
'arxiv.org',
'pubmed.ncbi.nlm.nih.gov',
'ieee.org',
'acm.org',
'scholar.google.com', 'arxiv.org', 'pubmed.ncbi.nlm.nih.gov', 'ieee.org', 'acm.org',
'sciencedirect.com', 'springer.com', 'jstor.org', 'researchgate.net', 'plos.org',
'nature.com', 'sciencemag.org', 'mit.edu', 'phys.org', 'semanticscholar.org',
'core.ac.uk', 'paperswithcode.com', 'base-search.net', 'doaj.org', 'worldscientific.com'
],
enabled: true,
},
@@ -538,11 +554,10 @@ export default function SettingsPage() {
id: 'tech',
name: 'Tech',
base_sites: [
'github.com',
'stackoverflow.com',
'dev.to',
'medium.com',
'reddit.com/r/programming',
'stackoverflow.com', 'github.com', 'reddit.com/r/programming', 'dev.to', 'hackernoon.com',
'tabnews.com.br', 'lobste.rs', 'news.ycombinator.com', 'hashnode.com', 'codesandbox.io',
'developer.mozilla.org', 'web.dev', 'smashingmagazine.com', 'martinfowler.com', 'infoq.com',
'dzone.com', 'freecodecamp.org', 'digitalocean.com', 'realpython.com', 'css-tricks.com'
],
enabled: true,
},
@@ -550,10 +565,10 @@ export default function SettingsPage() {
id: 'news',
name: 'News',
base_sites: [
'news.ycombinator.com',
'techcrunch.com',
'theverge.com',
'arstechnica.com',
'theverge.com', 'techcrunch.com', 'arstechnica.com', 'engadget.com', 'wired.com',
'venturebeat.com', 'cnet.com', 'mashable.com', 'gizmodo.com', 'zdnet.com',
'tomshardware.com', 'anandtech.com', 'bleepingcomputer.com', 'theregister.com', '9to5mac.com',
'androidauthority.com', 'techradar.com', 'fastcompany.com', 'g1.globo.com', 'techtudo.com.br'
],
enabled: true,
},
@@ -561,33 +576,47 @@ export default function SettingsPage() {
id: 'financeiro',
name: 'Financeiro',
base_sites: [
'bloomberg.com',
'reuters.com',
'financialtimes.com',
'wsj.com',
'bloomberg.com', 'reuters.com', 'wsj.com', 'ft.com', 'cnbc.com',
'marketwatch.com', 'economist.com', 'forbes.com', 'investopedia.com', 'seekingalpha.com',
'infomoney.com.br', 'valor.globo.com', 'exame.com', 'braziljournal.com', 'coindesk.com',
'cointelegraph.com', 'moneytimes.com.br', 'suno.com.br', 'b3.com.br', 'imf.org'
],
enabled: true,
},
],
};
await settings.saveSources(defaultConfig);
if (!settings.sourcesConfig) {
await settings.saveSources(curated);
} else {
const merged: SourcesConfig = {
...settings.sourcesConfig,
categories: settings.sourcesConfig.categories.map((existing) => {
const cur = curated.categories.find(c => c.id === existing.id);
if (!cur) return existing;
const set = new Set<string>([...existing.base_sites, ...cur.base_sites].map(s => s.toLowerCase()));
return { ...existing, base_sites: Array.from(set) } as any;
}),
last_updated: new Date().toISOString(),
};
await settings.saveSources(merged);
}
toast({
title: 'Padrões restaurados',
description: 'Categorias padrão foram restauradas',
title: 'Fontes mescladas',
description: 'Categorias curadas foram mescladas sem duplicatas',
});
} catch (error) {
toast({
title: 'Erro ao restaurar',
description: error instanceof Error ? error.message : 'Falha ao restaurar',
title: 'Erro ao mesclar',
description: error instanceof Error ? error.message : 'Falha ao mesclar categorias',
variant: 'destructive',
});
}
}
}}
>
<RotateCcw className="w-4 h-4 mr-2" />
Restaurar Padrões
</Button>
}}
>
<RotateCcw className="w-4 h-4 mr-2" />
Mesclar Padrões Curados
</Button>
</div>
<div className="grid gap-4">
{settings.sourcesConfig.categories.map((category) => {