feat: add emoji-regex and remove-accents dependencies; enhance chat and setup pages
- Added `emoji-regex` and `remove-accents` packages to improve text processing capabilities. - Implemented automatic Ollama startup in the chat page for better user experience. - Enhanced setup page to include GPU selection based on hardware specifications. - Updated chat input to support real-time validation and improved user feedback. - Refactored message handling in chat to incorporate new processing features.
This commit is contained in:
1 parent
4275295e4f
commit
84ea2dec19
51 files changed
+7857
-573
No files matched your search
+105
-51
@@ -1,13 +1,31 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Send, Square, Globe, WifiOff, ChevronUp } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle } from "lucide-react";
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { quickValidate, validateQuery } from "@/lib/query-validation";
|
||||
import { useSettingsStore, type SearchCategory } from "@/store/settings-store";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
@@ -31,6 +49,35 @@ export function ChatInput({
|
||||
const [input, setInput] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [webDialogOpen, setWebDialogOpen] = useState(false);
|
||||
const settings = useSettingsStore();
|
||||
|
||||
// Validação em tempo real
|
||||
const validation = useMemo(() => {
|
||||
if (!input.trim()) {
|
||||
return { isValid: true, errors: [], warnings: [] };
|
||||
}
|
||||
const preprocessingConfig = settings.queryPreprocessing || {
|
||||
enabled: true,
|
||||
minLength: 3,
|
||||
maxLength: 2000,
|
||||
autoSplitQuestions: true,
|
||||
irrelevantPatterns: [],
|
||||
};
|
||||
|
||||
if (!preprocessingConfig.enabled) {
|
||||
return { isValid: true, errors: [], warnings: [] };
|
||||
}
|
||||
|
||||
return validateQuery(input, {
|
||||
minLength: preprocessingConfig.minLength,
|
||||
maxLength: preprocessingConfig.maxLength,
|
||||
irrelevantPatterns: preprocessingConfig.irrelevantPatterns.length > 0
|
||||
? preprocessingConfig.irrelevantPatterns
|
||||
: undefined,
|
||||
});
|
||||
}, [input, settings.queryPreprocessing]);
|
||||
|
||||
const isValid = validation.isValid;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -40,7 +87,7 @@ export function ChatInput({
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (input.trim() && !isLoading) {
|
||||
if (input.trim() && !isLoading && isValid) {
|
||||
onSend(input);
|
||||
setInput("");
|
||||
}
|
||||
@@ -49,31 +96,37 @@ export function ChatInput({
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
const scrollHeight = textareaRef.current.scrollHeight;
|
||||
// Limitar altura máxima a uns 200px
|
||||
textareaRef.current.style.height = `${Math.min(scrollHeight, 200)}px`;
|
||||
}
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-transparent">
|
||||
<div className="relative max-w-3xl mx-auto w-full">
|
||||
<div className="flex-1 relative">
|
||||
<div className="w-full bg-transparent">
|
||||
<div className="relative w-full">
|
||||
<div className="flex-1 relative bg-background dark:bg-zinc-900/50 rounded-[2rem] border border-border/50 shadow-lg backdrop-blur-xl transition-all duration-200 focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/40 hover:border-primary/30">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua mensagem..."
|
||||
className="min-h-[48px] resize-none overflow-hidden py-4 pr-12 rounded-xl border shadow-lg"
|
||||
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 ${
|
||||
input.trim() && !isValid ? 'text-destructive' : ''
|
||||
}`}
|
||||
rows={1}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 flex items-center gap-2">
|
||||
|
||||
{/* Right Actions */}
|
||||
<div className="absolute right-3 bottom-2.5 flex items-center gap-1.5">
|
||||
{onWebSearchToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={webSearchEnabled ? "default" : "ghost"}
|
||||
variant={webSearchEnabled ? "ghost" : "ghost"}
|
||||
onClick={() => {
|
||||
if (!webSearchEnabled) {
|
||||
onWebSearchToggle(true);
|
||||
@@ -81,7 +134,11 @@ export function ChatInput({
|
||||
setWebDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
className="h-8 w-8"
|
||||
className={`h-9 w-9 rounded-full transition-all ${
|
||||
webSearchEnabled
|
||||
? "text-primary bg-primary/10 hover:bg-primary/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{webSearchEnabled ? (
|
||||
@@ -101,14 +158,15 @@ 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">
|
||||
<Button size="icon" variant="ghost" className="h-9 w-9 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted" title="Gerenciar fontes">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6}>
|
||||
<DropdownMenuContent align="end" sideOffset={12} className="w-56 rounded-xl">
|
||||
<DropdownMenuLabel>Fontes de conhecimento</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{categories.map((cat) => (
|
||||
@@ -123,68 +181,64 @@ export function ChatInput({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={onStop}
|
||||
className="h-9 w-9 rounded-full shadow-sm animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim()}
|
||||
className="h-8 w-8"
|
||||
disabled={!input.trim() || !isValid}
|
||||
className={`h-9 w-9 rounded-full transition-all shadow-sm ${
|
||||
input.trim() && isValid
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
<Send className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Errors */}
|
||||
{input.trim() && !isValid && validation.errors.length > 0 && (
|
||||
<div className="absolute -top-12 left-0 right-0 mx-auto w-max max-w-[90%] animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="bg-destructive/90 text-destructive-foreground text-xs px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 backdrop-blur-sm">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
<span>{validation.errors[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={webDialogOpen} onOpenChange={setWebDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-[425px] rounded-2xl">
|
||||
<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.
|
||||
Desativar a busca pode reduzir a qualidade em perguntas sobre fatos recentes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWebDialogOpen(false)}>Manter ativado</Button>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setWebDialogOpen(false)} className="rounded-full">Cancelar</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => { onWebSearchToggle?.(false); setWebDialogOpen(false); }}
|
||||
className="rounded-full"
|
||||
>
|
||||
Desativar pesquisa web
|
||||
Desativar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="text-xs text-center text-muted-foreground mt-2">
|
||||
OllaHub pode cometer erros. Verifique informações importantes.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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,71 +1,125 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } 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";
|
||||
import { MessageStepThinking } from "./message-step-thinking";
|
||||
import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) {
|
||||
// Verificar se é mensagem de processo de pensamento
|
||||
const isThinkingMessage = message.metadata &&
|
||||
typeof message.metadata === 'object' &&
|
||||
'type' in message.metadata &&
|
||||
message.metadata.type === 'thinking';
|
||||
|
||||
if (isThinkingMessage) {
|
||||
return <MessageStepThinking metadata={message.metadata as ThinkingMessageMetadata} />;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message }: { message: Message }) {
|
||||
const isUser = message.role === 'user';
|
||||
const [selected, setSelected] = useState(false);
|
||||
const isEmpty = !message.content || message.content.trim() === '';
|
||||
|
||||
// Memoizar processamento de conteúdo
|
||||
const processedContent = useMemo(() => {
|
||||
let content = message.content || '';
|
||||
// Remove metadata tags
|
||||
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||
|
||||
// Also remove common prefixes
|
||||
const prefixes = [
|
||||
'Bloco oculto de metadados:', 'Metadata:', 'Metadados:',
|
||||
'Hidden metadata block:', 'JSON metadata:', '---'
|
||||
];
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
return content || message.content;
|
||||
}, [message.content]);
|
||||
|
||||
return (
|
||||
<MessageBackground
|
||||
role={isUser ? 'user' : 'assistant'}
|
||||
state={selected ? 'selected' : 'normal'}
|
||||
className={cn("cursor-pointer")}
|
||||
className={cn(
|
||||
"cursor-default transition-colors rounded-xl",
|
||||
selected && "bg-muted/40",
|
||||
isUser ? "mb-2" : "mb-8"
|
||||
)}
|
||||
onClick={() => setSelected((v) => !v)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<div className={cn(
|
||||
"w-8 h-8 rounded-md flex items-center justify-center",
|
||||
isUser ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground"
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm ring-1 ring-border/50 backdrop-blur-sm",
|
||||
isUser
|
||||
? "bg-background text-muted-foreground"
|
||||
: "bg-primary/10 text-primary ring-primary/20"
|
||||
)}>
|
||||
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
||||
{isUser ? <User className="w-4 h-4" /> : <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 leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownRenderers}
|
||||
>
|
||||
{(() => {
|
||||
let content = message.content || '';
|
||||
// Remove metadata tags
|
||||
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||
|
||||
// Also remove common prefixes if they appear at the end
|
||||
const prefixes = [
|
||||
'Bloco oculto de metadados:',
|
||||
'Metadata:',
|
||||
'Metadados:',
|
||||
'Hidden metadata block:',
|
||||
'JSON metadata:',
|
||||
'---'
|
||||
];
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
return content || message.content;
|
||||
})()}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden min-w-0 pt-1">
|
||||
{isUser ? (
|
||||
// Estilo específico para mensagem do usuário (mais destaque, fonte maior)
|
||||
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap">
|
||||
{processedContent}
|
||||
</div>
|
||||
) : (
|
||||
// Estilo Assistant
|
||||
isEmpty && isStreaming ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium">Gerando resposta...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
|
||||
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
|
||||
"prose-p:leading-7 prose-li:leading-7",
|
||||
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
|
||||
isStreaming && "relative"
|
||||
)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownRenderers}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && !isEmpty && (
|
||||
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{!isUser && (
|
||||
<div className="absolute top-4 right-4">
|
||||
|
||||
{!isUser && !isStreaming && (
|
||||
<div className={cn(
|
||||
"absolute -bottom-8 left-12 flex items-center gap-2 opacity-0 transition-opacity duration-200",
|
||||
"group-hover:opacity-100",
|
||||
selected && "opacity-100"
|
||||
)}>
|
||||
<MessageActions content={message.content} role={message.role} />
|
||||
</div>
|
||||
)}
|
||||
</MessageBackground>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, RefreshCw, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: string | Error;
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorDisplay({ error, onRetry, onDismiss, className }: ErrorDisplayProps) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mb-3 px-4 py-3 rounded-lg border border-red-500/50 bg-red-500/10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-red-600 dark:text-red-400 mb-1">
|
||||
Erro ao processar
|
||||
</div>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 break-words">
|
||||
{errorMessage}
|
||||
</p>
|
||||
{(onRetry || onDismiss) && (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{onRetry && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
className="h-7 text-xs border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 mr-1.5" />
|
||||
Tentar novamente
|
||||
</Button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDismiss}
|
||||
className="h-7 text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
|
||||
>
|
||||
<X className="w-3 h-3 mr-1.5" />
|
||||
Fechar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,19 @@ export function MessageBackground({
|
||||
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 base = "group flex w-full gap-4 md:gap-6 relative transition-all duration-200";
|
||||
|
||||
// User: Fundo sutil, alinhado à direita (se quiséssemos, mas o layout atual é lista).
|
||||
// Vamos manter layout lista mas com estilo clean.
|
||||
// Perplexity usa: User message apenas texto bold/destacado, Assistant texto normal.
|
||||
|
||||
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" : "";
|
||||
? "bg-transparent text-foreground px-0 py-2" // Minimalista
|
||||
: "bg-transparent text-foreground/90 px-0 py-2";
|
||||
|
||||
// Estado de seleção sutil
|
||||
const stateCl = state === "selected" ? "bg-muted/30 -mx-4 px-4 rounded-lg" : "";
|
||||
|
||||
const hc = highContrast ? "hc:bg-message-high-contrast hc:text-foreground" : "";
|
||||
|
||||
return (
|
||||
@@ -34,4 +42,4 @@ export function MessageBackground({
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Search,
|
||||
Brain,
|
||||
Globe,
|
||||
Package,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||
|
||||
interface MessageStepThinkingProps {
|
||||
metadata: ThinkingMessageMetadata;
|
||||
}
|
||||
|
||||
export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
const getStepConfig = () => {
|
||||
switch (metadata.stepType) {
|
||||
case 'preprocessing':
|
||||
return {
|
||||
icon: Brain,
|
||||
textColor: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-900/20",
|
||||
};
|
||||
case 'web-research':
|
||||
return {
|
||||
icon: Globe,
|
||||
textColor: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-50 dark:bg-green-900/20",
|
||||
};
|
||||
case 'sources-found':
|
||||
return {
|
||||
icon: Package,
|
||||
textColor: "text-purple-600 dark:text-purple-400",
|
||||
bgColor: "bg-purple-50 dark:bg-purple-900/20",
|
||||
};
|
||||
case 'processing':
|
||||
return {
|
||||
icon: Brain,
|
||||
textColor: "text-indigo-600 dark:text-indigo-400",
|
||||
bgColor: "bg-indigo-50 dark:bg-indigo-900/20",
|
||||
};
|
||||
case 'response-generation':
|
||||
return {
|
||||
icon: Loader2,
|
||||
textColor: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-50 dark:bg-orange-900/20",
|
||||
};
|
||||
case 'fallback':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
textColor: "text-yellow-600 dark:text-yellow-400",
|
||||
bgColor: "bg-yellow-50 dark:bg-yellow-900/20",
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
icon: XCircle,
|
||||
textColor: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-50 dark:bg-red-900/20",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: Search,
|
||||
textColor: "text-muted-foreground",
|
||||
bgColor: "bg-muted/30",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getStepIcon = () => {
|
||||
const config = getStepConfig();
|
||||
const Icon = config.icon;
|
||||
const isSpinning = metadata.stepType === 'response-generation' && metadata.status === 'running';
|
||||
|
||||
return <Icon className={cn("w-4 h-4", config.textColor, isSpinning && "animate-spin")} />;
|
||||
};
|
||||
|
||||
const copyToClipboard = async (url: string, index: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStepConfig();
|
||||
|
||||
return (
|
||||
<div className="mb-4 ml-10 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className={cn(
|
||||
"flex items-center gap-3 p-2 rounded-lg transition-all w-fit max-w-full",
|
||||
config.bgColor
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStepIcon()}
|
||||
<span className={cn("text-sm font-medium", config.textColor)}>
|
||||
{metadata.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{metadata.status === 'running' && (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground/50" />
|
||||
)}
|
||||
|
||||
{(metadata.details || metadata.sources || metadata.error) && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-3.5 h-3.5 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar Compacta */}
|
||||
{metadata.progress !== undefined && metadata.progress < 100 && (
|
||||
<div className="ml-1 mt-1 w-32 h-1 bg-muted/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn("h-full transition-all duration-300", config.textColor.replace('text-', 'bg-'))}
|
||||
style={{ width: `${metadata.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conteúdo Expandido */}
|
||||
{isExpanded && (
|
||||
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-2 text-sm text-muted-foreground animate-in fade-in">
|
||||
{metadata.details && (
|
||||
<div className="whitespace-pre-wrap">{metadata.details}</div>
|
||||
)}
|
||||
|
||||
{metadata.sources && metadata.sources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{metadata.sources.map((source, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
|
||||
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||
<a href={source.url} target="_blank" className="truncate hover:underline flex-1">{source.title || source.url}</a>
|
||||
<button onClick={() => copyToClipboard(source.url, idx)} className="ml-1 opacity-50 hover:opacity-100">
|
||||
{copiedIndex === idx ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metadata.error && (
|
||||
<div className="text-red-500 font-medium flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4" />
|
||||
{metadata.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
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 { useState, useEffect, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
interface ModelDownloadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: (modelName: string) => void;
|
||||
}
|
||||
|
||||
interface DownloadInfo {
|
||||
status: string;
|
||||
percent?: number;
|
||||
downloaded?: string;
|
||||
total?: string;
|
||||
speed?: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) {
|
||||
const [modelName, setModelName] = useState("");
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [downloadStatus, setDownloadStatus] = useState("");
|
||||
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
const isDownloadingRef = useRef(false);
|
||||
const modelNameRef = useRef("");
|
||||
|
||||
// Atualizar refs quando estado muda
|
||||
useEffect(() => {
|
||||
isDownloadingRef.current = isDownloading;
|
||||
modelNameRef.current = modelName;
|
||||
}, [isDownloading, modelName]);
|
||||
|
||||
// Resetar estado quando dialog abre/fecha
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setModelName("");
|
||||
setIsDownloading(false);
|
||||
setDownloadProgress(0);
|
||||
setDownloadStatus("");
|
||||
setDownloadInfo(null);
|
||||
setError(null);
|
||||
setIsInstalled(false);
|
||||
isDownloadingRef.current = false;
|
||||
modelNameRef.current = "";
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Verificar se modelo já está instalado quando nome muda
|
||||
useEffect(() => {
|
||||
if (modelName.trim() && !isDownloading) {
|
||||
checkInstallation(modelName.trim());
|
||||
}
|
||||
}, [modelName, isDownloading]);
|
||||
|
||||
// Listener para progresso do download - sempre ativo quando dialog está aberto
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
let unlistenFn: (() => void) | null = null;
|
||||
|
||||
const setupListener = async () => {
|
||||
const unlisten = await listen<string>('download-progress', (event) => {
|
||||
// Só processar se estiver fazendo download (usar ref para evitar stale closure)
|
||||
if (!isDownloadingRef.current) return;
|
||||
|
||||
const payload = event.payload;
|
||||
|
||||
// Tentar parsear como JSON estruturado
|
||||
let info: DownloadInfo | null = null;
|
||||
try {
|
||||
info = JSON.parse(payload) as DownloadInfo;
|
||||
} catch {
|
||||
// Fallback: tratar como string raw
|
||||
info = {
|
||||
status: "downloading",
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (info) {
|
||||
setDownloadInfo(info);
|
||||
|
||||
// Atualizar status text
|
||||
const statusText = info.status === "pulling" ? "Baixando modelo..." :
|
||||
info.status === "verifying" ? "Verificando..." :
|
||||
info.status === "writing" ? "Salvando..." :
|
||||
info.status === "success" ? "Concluído!" :
|
||||
"Baixando...";
|
||||
setDownloadStatus(statusText);
|
||||
|
||||
// Atualizar progresso
|
||||
if (info.percent !== undefined) {
|
||||
setDownloadProgress(info.percent);
|
||||
} else {
|
||||
// Fallback: estimar progresso baseado no status
|
||||
if (info.status === "pulling") {
|
||||
setDownloadProgress(prev => prev < 10 ? 10 : prev);
|
||||
} else if (info.status === "verifying") {
|
||||
setDownloadProgress(90);
|
||||
} else if (info.status === "writing") {
|
||||
setDownloadProgress(95);
|
||||
}
|
||||
}
|
||||
|
||||
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay
|
||||
if (info.percent === 100 || info.status === "success") {
|
||||
setTimeout(async () => {
|
||||
setIsInstalled(true);
|
||||
setIsDownloading(false);
|
||||
isDownloadingRef.current = false;
|
||||
const currentModelName = modelNameRef.current.trim();
|
||||
await checkInstallation(currentModelName);
|
||||
// Após verificar instalação, chamar onSuccess e fechar
|
||||
if (onSuccess) {
|
||||
onSuccess(currentModelName);
|
||||
}
|
||||
onOpenChange(false);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
unlistenFn = unlisten;
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlistenFn) {
|
||||
unlistenFn();
|
||||
}
|
||||
};
|
||||
}, [open, onSuccess, onOpenChange]);
|
||||
|
||||
const checkInstallation = async (name: string) => {
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_if_model_installed', { name });
|
||||
setIsInstalled(installed);
|
||||
if (installed) {
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Check failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const validateModelName = (name: string): string | null => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return "Nome do modelo não pode estar vazio";
|
||||
}
|
||||
// Validar formato: aceita letras, números, pontos, underscores, hífens, dois pontos e barras
|
||||
// Exemplos válidos: "llama3.2:1b", "huihui_ai/qwen3-abliterated:1.7b", "mistral:latest"
|
||||
if (!/^[a-zA-Z0-9._:\/-]+$/.test(trimmed)) {
|
||||
return "Nome do modelo contém caracteres inválidos";
|
||||
}
|
||||
// Não pode começar ou terminar com barra, dois pontos ou hífen
|
||||
if (/^[\/:\-]|[\/:\-]$/.test(trimmed)) {
|
||||
return "Nome do modelo não pode começar ou terminar com /, : ou -";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
const trimmedName = modelName.trim();
|
||||
const validationError = validateModelName(trimmedName);
|
||||
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
// Se já está instalado, apenas fechar e notificar sucesso
|
||||
handleSuccess(trimmedName);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloading(true);
|
||||
isDownloadingRef.current = true;
|
||||
setDownloadProgress(0);
|
||||
setDownloadStatus("Iniciando download...");
|
||||
setError(null);
|
||||
modelNameRef.current = trimmedName;
|
||||
|
||||
try {
|
||||
await invoke('pull_model', { name: trimmedName });
|
||||
// O status será atualizado pelo listener quando o download completar
|
||||
} catch (error: any) {
|
||||
console.error("Download failed", error);
|
||||
setError(error?.message || "Falha no download. Tente novamente.");
|
||||
setDownloadStatus("");
|
||||
setDownloadProgress(0);
|
||||
setIsDownloading(false);
|
||||
isDownloadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = (name: string) => {
|
||||
// Só fechar e chamar onSuccess se realmente completou o download
|
||||
// Se já estava instalado, também chamar onSuccess
|
||||
if (onSuccess) {
|
||||
onSuccess(name);
|
||||
}
|
||||
// Não fechar automaticamente se foi apenas verificação de instalação
|
||||
// Deixar o usuário decidir quando fechar
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Permitir fechar sempre, mas avisar se estiver baixando
|
||||
if (isDownloading) {
|
||||
const confirmed = window.confirm("O download está em andamento. Deseja realmente fechar? O download continuará em segundo plano.");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Cancelar download (por enquanto apenas fecha o dialog e para o estado)
|
||||
// TODO: Implementar cancelamento real do processo ollama pull
|
||||
if (isDownloading) {
|
||||
const confirmed = window.confirm("Deseja cancelar o download? O processo continuará em segundo plano.");
|
||||
if (confirmed) {
|
||||
setIsDownloading(false);
|
||||
isDownloadingRef.current = false;
|
||||
setDownloadProgress(0);
|
||||
setDownloadStatus("");
|
||||
onOpenChange(false);
|
||||
}
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = modelName.trim().length > 0 && !validateModelName(modelName.trim());
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Adicionar Modelo</DialogTitle>
|
||||
<DialogDescription>
|
||||
Digite o nome do modelo do Ollama que deseja baixar (ex: llama3.2:1b, mistral:latest)
|
||||
</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 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{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
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown, ChevronUp, Clock, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ProcessStep {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
timestamp?: number;
|
||||
duration?: number;
|
||||
details?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PostProcessingPanelProps {
|
||||
steps: ProcessStep[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PostProcessingPanel({ steps, className }: PostProcessingPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (!steps || steps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedSteps = steps.filter(s => s.status === 'completed').length;
|
||||
const hasErrors = steps.some(s => s.status === 'error');
|
||||
const isRunning = steps.some(s => s.status === 'running');
|
||||
|
||||
const getStepIcon = (step: ProcessStep) => {
|
||||
switch (step.status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />;
|
||||
case 'error':
|
||||
return <XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />;
|
||||
case 'running':
|
||||
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms?: number) => {
|
||||
if (!ms) return '';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("border-t border-muted bg-muted/20", className)}>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-6 py-3 flex items-center justify-between hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasErrors ? (
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
) : isRunning ? (
|
||||
<Loader2 className="w-4 h-4 text-primary animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
Processo de Pensamento
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({completedSteps}/{steps.length} etapas)
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-6 pb-4 space-y-3">
|
||||
{steps.map((step, idx) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border transition-all",
|
||||
step.status === 'completed' && "bg-green-500/5 border-green-500/20",
|
||||
step.status === 'error' && "bg-red-500/5 border-red-500/20",
|
||||
step.status === 'running' && "bg-primary/5 border-primary/20",
|
||||
step.status === 'pending' && "bg-muted/50 border-muted"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
{getStepIcon(step)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{step.label}</span>
|
||||
{step.duration && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatDuration(step.duration)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{step.details && (
|
||||
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap">
|
||||
{step.details}
|
||||
</p>
|
||||
)}
|
||||
{step.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{step.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { Search, Brain, Package, Loader2, CheckCircle2, Globe } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export type ProcessingStep =
|
||||
| 'collecting'
|
||||
| 'processing'
|
||||
| 'searching'
|
||||
| 'preparing'
|
||||
| 'streaming';
|
||||
|
||||
interface ProcessingIndicatorProps {
|
||||
currentStep: ProcessingStep;
|
||||
progress?: number; // 0-100
|
||||
sourcesCount?: number;
|
||||
}
|
||||
|
||||
export function ProcessingIndicator({
|
||||
currentStep,
|
||||
progress = 0,
|
||||
sourcesCount = 0,
|
||||
}: ProcessingIndicatorProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fade-in animation
|
||||
setIsVisible(true);
|
||||
}, []);
|
||||
|
||||
const steps: Array<{
|
||||
key: ProcessingStep;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
emoji: string;
|
||||
}> = [
|
||||
{
|
||||
key: 'collecting',
|
||||
label: 'Coletando Dados',
|
||||
icon: <Search className="w-4 h-4" />,
|
||||
emoji: '🔍',
|
||||
},
|
||||
{
|
||||
key: 'processing',
|
||||
label: 'Processando...',
|
||||
icon: <Brain className="w-4 h-4" />,
|
||||
emoji: '🤖',
|
||||
},
|
||||
{
|
||||
key: 'searching',
|
||||
label: sourcesCount > 0 ? `Buscando na Web (${sourcesCount} fontes)...` : 'Buscando na Web...',
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
emoji: '🌐',
|
||||
},
|
||||
{
|
||||
key: 'preparing',
|
||||
label: 'Preparando Contexto',
|
||||
icon: <Package className="w-4 h-4" />,
|
||||
emoji: '📦',
|
||||
},
|
||||
{
|
||||
key: 'streaming',
|
||||
label: 'Gerando Resposta...',
|
||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||
emoji: '💬',
|
||||
},
|
||||
];
|
||||
|
||||
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
|
||||
const currentStepData = steps[currentStepIndex] || steps[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mb-3 px-4 py-3 rounded-lg border border-muted/50 bg-muted/20 transition-all duration-300",
|
||||
isVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
|
||||
)}
|
||||
>
|
||||
{/* Card de Status Atual */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
{currentStep === 'collecting' && (
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-ping" />
|
||||
)}
|
||||
{currentStep === 'processing' && (
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-pulse" />
|
||||
)}
|
||||
{currentStep === 'searching' && (
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-spin" style={{ animationDuration: '2s' }} />
|
||||
)}
|
||||
<span className="text-xl relative z-10">{currentStepData.emoji}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{currentStepData.label}
|
||||
</div>
|
||||
{progress > 0 && progress < 100 && (
|
||||
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Visual */}
|
||||
<div className="hidden md:flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
|
||||
const isActive = idx === currentStepIndex;
|
||||
const isPast = idx < currentStepIndex;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-300",
|
||||
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
|
||||
isActive && "bg-primary/20 text-primary animate-pulse",
|
||||
!isActive && !isPast && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPast ? (
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
) : isActive ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
step.icon
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-colors duration-300",
|
||||
isActive && "text-foreground font-medium",
|
||||
isPast && "text-muted-foreground opacity-60"
|
||||
)}
|
||||
>
|
||||
{step.label.split(' ')[0]} {/* Mostrar apenas primeira palavra na timeline */}
|
||||
</span>
|
||||
{idx < currentStepIndex && (
|
||||
<div className="w-6 h-px bg-muted mx-1" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Timeline Vertical para Mobile */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
|
||||
const isActive = idx === currentStepIndex;
|
||||
const isPast = idx < currentStepIndex;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-5 h-5 rounded-full transition-all",
|
||||
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
|
||||
isActive && "bg-primary/20 text-primary animate-pulse",
|
||||
!isActive && !isPast && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isPast ? (
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
) : isActive ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
step.icon
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs transition-colors",
|
||||
isActive && "text-foreground font-medium",
|
||||
isPast && "text-muted-foreground opacity-60"
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, Copy, Check, Globe } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { ScrapedContent } from '@/services/webSearch';
|
||||
|
||||
interface SourcesDisplayProps {
|
||||
sources: ScrapedContent[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SourcesDisplay({ sources, className }: SourcesDisplayProps) {
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
|
||||
if (!sources || sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const copyToClipboard = async (url: string, index: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiedIndex(index);
|
||||
setTimeout(() => setCopiedIndex(null), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy URL:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Memoizar URLs de favicon para evitar recálculos
|
||||
const faviconUrls = useMemo(() => {
|
||||
return sources.map(source => {
|
||||
try {
|
||||
const domain = new URL(source.url).hostname;
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}, [sources]);
|
||||
|
||||
return (
|
||||
<div className={cn("px-6 py-4 border-t border-muted bg-muted/20", className)}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Fontes consultadas ({sources.length}):
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
|
||||
{sources.map((source, idx) => {
|
||||
const faviconUrl = faviconUrls[idx];
|
||||
const isCopied = copiedIndex === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="group flex-shrink-0 flex items-center gap-2 px-3 py-2 rounded-md bg-background hover:bg-muted/80 transition-all duration-200 border border-muted hover:border-primary/50 shadow-sm hover:shadow-md"
|
||||
>
|
||||
{/* Favicon */}
|
||||
{faviconUrl ? (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-4 h-4 flex-shrink-0 rounded-sm"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Globe className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* Title and URL */}
|
||||
<div className="flex-1 min-w-0 max-w-[200px] md:max-w-[300px]">
|
||||
<div className="text-xs font-medium text-foreground truncate" title={source.title || source.url}>
|
||||
{source.title || `Fonte ${idx + 1}`}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate" title={source.url}>
|
||||
{source.url.replace(/^https?:\/\//, '').replace(/^www\./, '')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={() => copyToClipboard(source.url, idx)}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title="Copiar URL"
|
||||
aria-label="Copiar URL"
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* External Link Button */}
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title="Abrir em nova aba"
|
||||
aria-label="Abrir em nova aba"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface StreamingIndicatorProps {
|
||||
isStreaming: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StreamingIndicator({ isStreaming, className }: StreamingIndicatorProps) {
|
||||
const [dots, setDots] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) {
|
||||
setDots('');
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setDots(prev => {
|
||||
if (prev === '...') return '';
|
||||
return prev + '.';
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreaming]);
|
||||
|
||||
if (!isStreaming) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-xs text-muted-foreground px-6 py-2", className)}>
|
||||
<div className="relative">
|
||||
<Sparkles className="w-3 h-3 text-primary animate-pulse" />
|
||||
<div className="absolute inset-0 bg-primary/20 rounded-full animate-ping" />
|
||||
</div>
|
||||
<span>Streaming{dots}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
export type ThinkingStep =
|
||||
| 'analyzing'
|
||||
| 'planning'
|
||||
| 'searching'
|
||||
| 'reading'
|
||||
| 'validating'
|
||||
| 'formulating'
|
||||
| 'complete';
|
||||
|
||||
@@ -13,6 +15,7 @@ interface ThinkingIndicatorProps {
|
||||
searchQuery?: string;
|
||||
sourcesRead?: number;
|
||||
totalSources?: number;
|
||||
planSize?: number;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
@@ -21,6 +24,7 @@ export function ThinkingIndicator({
|
||||
searchQuery,
|
||||
sourcesRead = 0,
|
||||
totalSources = 0,
|
||||
planSize = 0,
|
||||
}: ThinkingIndicatorProps) {
|
||||
const steps: Array<{
|
||||
key: ThinkingStep;
|
||||
@@ -29,21 +33,29 @@ export function ThinkingIndicator({
|
||||
}> = [
|
||||
{
|
||||
key: 'analyzing',
|
||||
label: 'Analisando intenção...',
|
||||
label: 'Analisando...',
|
||||
icon: <Brain className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'planning',
|
||||
label: 'Planejando pesquisa...',
|
||||
icon: <Brain className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'searching',
|
||||
label: searchQuery ? `Pesquisando: "${searchQuery}"` : 'Pesquisando na web...',
|
||||
label: planSize > 0 ? `Pesquisando (${planSize} tópicos)...` : 'Pesquisando na web...',
|
||||
icon: <Search className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'reading',
|
||||
label: totalSources > 0
|
||||
? `Lendo ${sourcesRead}/${totalSources} fontes...`
|
||||
: 'Lendo conteúdo...',
|
||||
label: 'Lendo conteúdo...',
|
||||
icon: <BookOpen className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'validating',
|
||||
label: 'Validando dados...',
|
||||
icon: <CheckCircle2 className="w-3 h-3" />,
|
||||
},
|
||||
{
|
||||
key: 'formulating',
|
||||
label: 'Formulando resposta...',
|
||||
|
||||
Reference in new issue
Block a user