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
+64
-87
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user