feat: add database support, conversation sidebar, and improve chat streaming
- Update .gitignore to exclude database files, extensions, and .cursor directory - Enhance chat streaming API with safe enqueue and close functions to prevent errors - Integrate ConversationSidebar component into ClientHome for better UI - Add dependencies like better-sqlite3 and esbuild packages for database functionality This commit introduces database persistence for conversations, improves error handling in streaming responses, and adds a sidebar for managing chats, enhancing overall app robustness and user experience.
This commit is contained in:
1 parent
a3f8226fda
commit
0cc7a81319
34 files changed
+2370
-198
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { ChatContainer } from "@/components/chat/ChatContainer";
|
||||
import { ConversationSidebar } from "@/components/chat/ConversationSidebar";
|
||||
import {
|
||||
SettingsButton,
|
||||
SettingsModal,
|
||||
@@ -15,6 +16,23 @@ interface ClientHomeProps {
|
||||
|
||||
export function ClientHome({ offline, models }: ClientHomeProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [currentConversationId, setCurrentConversationId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const handleConversationCreated = () => {
|
||||
// Incrementar trigger para forçar atualização da sidebar
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleSelectConversation = (id: string) => {
|
||||
setCurrentConversationId(id);
|
||||
};
|
||||
|
||||
const handleNewConversation = () => {
|
||||
setCurrentConversationId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-[var(--background)]">
|
||||
@@ -33,7 +51,19 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<ChatContainer models={models} offline={offline} />
|
||||
<div className="flex h-full">
|
||||
<ConversationSidebar
|
||||
currentConversationId={currentConversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
<ChatContainer
|
||||
models={models}
|
||||
offline={offline}
|
||||
onConversationCreated={handleConversationCreated}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ModelDropdown } from "@/components/model/ModelDropdown";
|
||||
import { ModelPullDialog } from "@/components/model/ModelPullDialog";
|
||||
import { ChatMessage, ChatInput } from "@/components/chat/ChatMessage";
|
||||
import { SystemPrompt } from "@/components/chat/SystemPrompt";
|
||||
import { ChatMessage } from "@/components/chat/ChatMessage";
|
||||
import { SettingsModal } from "@/components/chat/SettingsModal";
|
||||
import { Send, Square } from "lucide-react";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
import type { Message } from "@/lib/chat";
|
||||
import { createMessage } from "@/lib/chat";
|
||||
@@ -13,20 +14,40 @@ import { useUserPrefs, buildOllamaOptions } from "@/hooks/useUserPrefs";
|
||||
interface ChatContainerProps {
|
||||
models: ModelInfo[];
|
||||
offline: boolean;
|
||||
onConversationCreated?: () => void;
|
||||
}
|
||||
|
||||
export function ChatContainer({ models, offline }: ChatContainerProps) {
|
||||
const { prefs, ready } = useUserPrefs();
|
||||
export function ChatContainer({
|
||||
models,
|
||||
offline,
|
||||
onConversationCreated,
|
||||
}: ChatContainerProps) {
|
||||
const { prefs, ready, update } = useUserPrefs();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [streamingContent, setStreamingContent] = useState("");
|
||||
const [inputContent, setInputContent] = useState("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const [showPullDialog, setShowPullDialog] = useState(false);
|
||||
const [modelToPull, setModelToPull] = useState<string>("");
|
||||
const [systemPrompt, setSystemPrompt] = useState<string>("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Carregar modelo salvo ao iniciar
|
||||
useEffect(() => {
|
||||
if (ready && prefs.selectedModel && !selectedModel) {
|
||||
setSelectedModel(prefs.selectedModel);
|
||||
}
|
||||
}, [ready, prefs.selectedModel]);
|
||||
|
||||
// Salvar modelo quando selecionado
|
||||
const handleModelChange = (modelName: string) => {
|
||||
setSelectedModel(modelName);
|
||||
update({ selectedModel: modelName }); // Salvar no Redis
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
@@ -35,9 +56,55 @@ export function ChatContainer({ models, offline }: ChatContainerProps) {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
const loadConversation = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/conversations/${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.conversation && data.messages) {
|
||||
setConversationId(id);
|
||||
setMessages(data.messages);
|
||||
setSelectedModel(data.conversation.model);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar conversa:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const startNewConversation = () => {
|
||||
setConversationId(null);
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
if (!selectedModel || isStreaming) return;
|
||||
|
||||
// Criar nova conversa se não existir (COM TÍTULO)
|
||||
let currentConversationId = conversationId;
|
||||
if (!currentConversationId) {
|
||||
try {
|
||||
const response = await fetch("/api/conversations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
firstMessage: content, // Passar primeira mensagem
|
||||
}),
|
||||
});
|
||||
const { id, title } = await response.json();
|
||||
currentConversationId = id;
|
||||
setConversationId(id);
|
||||
console.log("✨ Chat criado:", title);
|
||||
|
||||
// Notificar sidebar para atualizar lista de conversas
|
||||
if (onConversationCreated) {
|
||||
onConversationCreated();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar conversa:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const userMessage = createMessage("user", content);
|
||||
const newMessages = [...messages, userMessage];
|
||||
|
||||
@@ -51,6 +118,19 @@ export function ChatContainer({ models, offline }: ChatContainerProps) {
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
|
||||
// Persistir mensagem do usuário
|
||||
if (currentConversationId) {
|
||||
try {
|
||||
await fetch(`/api/conversations/${currentConversationId}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: "user", content }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao persistir mensagem:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const options = buildOllamaOptions(
|
||||
prefs.device,
|
||||
@@ -105,6 +185,28 @@ export function ChatContainer({ models, offline }: ChatContainerProps) {
|
||||
);
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setStreamingContent("");
|
||||
|
||||
// Persistir mensagem do assistente
|
||||
if (currentConversationId) {
|
||||
try {
|
||||
await fetch(
|
||||
`/api/conversations/${currentConversationId}/messages`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
}),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Erro ao persistir mensagem do assistente:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
@@ -128,16 +230,36 @@ export function ChatContainer({ models, offline }: ChatContainerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (inputContent.trim() && !isStreaming) {
|
||||
handleSendMessage(inputContent.trim());
|
||||
setInputContent("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortRef.current && isStreaming) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSystemPromptChange = (prompt: string) => {
|
||||
setSystemPrompt(prompt);
|
||||
update({ systemPrompt: prompt }); // Salvar no SQLite
|
||||
};
|
||||
|
||||
const handleSelectModel = (modelId: string) => {
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
if (model) {
|
||||
setSelectedModel(modelId);
|
||||
handleModelChange(modelId);
|
||||
// Optional: clear chat when switching models
|
||||
// setMessages([]);
|
||||
}
|
||||
@@ -191,97 +313,98 @@ export function ChatContainer({ models, offline }: ChatContainerProps) {
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-[var(--background)]">
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 border-r border-[var(--border)] bg-[var(--surface)] p-4 overflow-y-auto">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-medium text-[var(--foreground)]/80 mb-2">
|
||||
Models
|
||||
</h2>
|
||||
<ModelDropdown
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={handleSelectModel}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{offline && (
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<p className="text-sm text-orange-800">
|
||||
Ollama is offline. Using mock data.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedModelData && (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-3">
|
||||
<h3 className="font-medium text-sm mb-2">Selected Model</h3>
|
||||
<div className="space-y-1 text-xs text-[var(--foreground)]/80">
|
||||
<div>Name: {selectedModelData.name}</div>
|
||||
<div>Size: {selectedModelData.sizeGB} GB</div>
|
||||
<div>Quantization: {selectedModelData.quantization}</div>
|
||||
<div>Device: {selectedModelData.device}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SystemPrompt onSystemPromptChange={setSystemPrompt} />
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Chat Header */}
|
||||
<div className="border-b border-[var(--border)] p-4 flex items-center justify-between gap-3">
|
||||
<div className="flex-1 flex flex-col bg-[var(--background)] min-h-0">
|
||||
{/* Chat Header */}
|
||||
<div className="border-b border-[var(--border)] p-4 flex items-center justify-between gap-3 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{selectedModelData
|
||||
? `Chat with ${selectedModelData.name}`
|
||||
? "Chat with"
|
||||
: "Select a model to start chatting"}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={!isStreaming}
|
||||
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-[var(--foreground)]/60">
|
||||
<p className="text-lg mb-2">Welcome to Ollahub</p>
|
||||
<p className="text-sm">Select a model and start chatting!</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<ChatMessage
|
||||
message={createMessage("assistant", streamingContent)}
|
||||
isStreaming={true}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
{selectedModelData && (
|
||||
<ModelDropdown
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={handleSelectModel}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsModal
|
||||
systemPrompt={systemPrompt}
|
||||
onSystemPromptChange={handleSystemPromptChange}
|
||||
selectedModel={selectedModel}
|
||||
models={models}
|
||||
/>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={!isStreaming}
|
||||
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
disabled={!selectedModel || isStreaming}
|
||||
placeholder={
|
||||
selectedModel ? "Type a message..." : "Select a model first..."
|
||||
}
|
||||
/>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-[var(--foreground)]/60">
|
||||
<p className="text-lg mb-2">Welcome to Ollahub</p>
|
||||
<p className="text-sm">Select a model and start chatting!</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<ChatMessage
|
||||
message={createMessage("assistant", streamingContent)}
|
||||
isStreaming={true}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--background)]">
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={inputContent}
|
||||
onChange={(e) => setInputContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
selectedModel ? "Type a message..." : "Select a model first..."
|
||||
}
|
||||
disabled={!selectedModel || isStreaming}
|
||||
className="flex-1 resize-none rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)] disabled:opacity-50"
|
||||
rows={1}
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button
|
||||
type={isStreaming ? "button" : "submit"}
|
||||
onClick={isStreaming ? handleStop : undefined}
|
||||
disabled={
|
||||
isStreaming ? false : !inputContent.trim() || !selectedModel
|
||||
}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Pull Dialog */}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { User, Bot, Send, Loader2 } from "lucide-react";
|
||||
import { User, Bot, Send, Loader2, Square } from "lucide-react";
|
||||
import { MarkdownRenderer } from "@/components/message/MarkdownRenderer";
|
||||
import { MessageActions } from "@/components/message/MessageActions";
|
||||
import { extractCodeBlocks } from "@/hooks/useMarkdown";
|
||||
import type { Message } from "@/lib/chat";
|
||||
import { formatTimestamp } from "@/lib/chat";
|
||||
|
||||
@@ -29,7 +28,7 @@ export function ChatMessage({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-4 py-2 ${
|
||||
className={`max-w-[80%] relative rounded-lg px-4 py-2 ${
|
||||
isUser
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--surface)] text-[var(--foreground)]"
|
||||
@@ -44,11 +43,8 @@ export function ChatMessage({
|
||||
</div>
|
||||
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
||||
{!isUser && !isStreaming && (
|
||||
<div className="mt-2">
|
||||
<MessageActions
|
||||
content={message.content}
|
||||
extractCodeBlocks={extractCodeBlocks}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<MessageActions content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -71,13 +67,17 @@ export function ChatMessage({
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (content: string) => void;
|
||||
onStop?: () => void;
|
||||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSendMessage,
|
||||
onStop,
|
||||
disabled = false,
|
||||
isStreaming = false,
|
||||
placeholder = "Type a message...",
|
||||
}: ChatInputProps) {
|
||||
const [content, setContent] = useState("");
|
||||
@@ -125,11 +125,14 @@ export function ChatInput({
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() || disabled}
|
||||
type={isStreaming ? "button" : "submit"}
|
||||
onClick={isStreaming ? onStop : undefined}
|
||||
disabled={isStreaming ? false : !content.trim() || disabled}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
||||
>
|
||||
{disabled ? (
|
||||
{isStreaming ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : disabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { MessageSquare, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
type Conversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
updated_at: number;
|
||||
};
|
||||
|
||||
type ConversationSidebarProps = {
|
||||
currentConversationId: string | null;
|
||||
onSelectConversation: (id: string) => void;
|
||||
onNewConversation: () => void;
|
||||
refreshTrigger?: number; // Para forçar atualização
|
||||
};
|
||||
|
||||
export function ConversationSidebar({
|
||||
currentConversationId,
|
||||
onSelectConversation,
|
||||
onNewConversation,
|
||||
refreshTrigger,
|
||||
}: ConversationSidebarProps) {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, []);
|
||||
|
||||
// Recarregar conversas quando refreshTrigger mudar
|
||||
useEffect(() => {
|
||||
if (refreshTrigger !== undefined) {
|
||||
loadConversations();
|
||||
}
|
||||
}, [refreshTrigger]);
|
||||
|
||||
const loadConversations = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/conversations");
|
||||
const data = await response.json();
|
||||
setConversations(data);
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar conversas:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!confirm("Deletar esta conversa?")) return;
|
||||
|
||||
try {
|
||||
await fetch(`/api/conversations/${id}`, { method: "DELETE" });
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||
|
||||
if (currentConversationId === id) {
|
||||
onNewConversation();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar conversa:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 h-full bg-[var(--surface)] border-r border-[var(--border)] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[var(--border)]">
|
||||
<button
|
||||
onClick={onNewConversation}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Nova Conversa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lista de Conversas */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
||||
Carregando...
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
||||
Nenhuma conversa ainda
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
<div
|
||||
key={conv.id}
|
||||
onClick={() => onSelectConversation(conv.id)}
|
||||
className={`w-full flex items-center gap-2 p-3 rounded-lg mb-1 text-left transition-colors group cursor-pointer ${
|
||||
currentConversationId === conv.id
|
||||
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||
: "hover:bg-[var(--background)]"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-sm truncate">{conv.title}</span>
|
||||
<button
|
||||
onClick={(e) => handleDelete(conv.id, e)}
|
||||
className="p-1 hover:bg-[var(--background)] rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Settings, X } from "lucide-react";
|
||||
|
||||
type SettingsModalProps = {
|
||||
systemPrompt: string;
|
||||
onSystemPromptChange: (prompt: string) => void;
|
||||
selectedModel: string | null;
|
||||
models: Array<{ id: string; name: string; device: string }>;
|
||||
};
|
||||
|
||||
export function SettingsModal({
|
||||
systemPrompt,
|
||||
onSystemPromptChange,
|
||||
selectedModel,
|
||||
models,
|
||||
}: SettingsModalProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tempPrompt, setTempPrompt] = useState(systemPrompt);
|
||||
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
const handleSave = () => {
|
||||
onSystemPromptChange(tempPrompt);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setTempPrompt(systemPrompt);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Botão de Configurações */}
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="p-2 rounded-lg hover:bg-[var(--surface)] transition-colors"
|
||||
aria-label="Configurações"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[var(--background)] rounded-lg border border-[var(--border)] w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
|
||||
<h2 className="text-lg font-semibold">Configurações</h2>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-1 rounded hover:bg-[var(--surface)] transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-6 overflow-y-auto max-h-[60vh]">
|
||||
{/* Model Info */}
|
||||
{selectedModelData && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">Modelo Selecionado</h3>
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface)] p-3">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--foreground)]/60">Nome:</span>
|
||||
<span>{selectedModelData.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--foreground)]/60">Dispositivo:</span>
|
||||
<span className="capitalize">{selectedModelData.device}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* System Prompt */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">System Prompt</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-[var(--foreground)]/80">
|
||||
Instruções para o modelo (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={tempPrompt}
|
||||
onChange={(e) => setTempPrompt(e.target.value)}
|
||||
placeholder="Ex: Você é um assistente útil e prestativo..."
|
||||
className="w-full h-32 p-3 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] resize-none focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Este prompt será usado como contexto para todas as mensagens desta conversa.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-[var(--border)]">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface)] transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Salvar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
type MarkdownRendererProps = {
|
||||
content: string;
|
||||
@@ -15,6 +16,56 @@ export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }: any) {
|
||||
const text = String(children || "");
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code className="rounded bg-[var(--surface)] px-1 py-0.5 text-[0.95em]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative my-3 rounded-lg border border-[var(--border)] bg-[var(--background)]">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 rounded px-2 py-1 text-xs bg-[var(--surface)]/70 hover:bg-[var(--surface)] transition-colors"
|
||||
onClick={() => navigator.clipboard.writeText(text)}
|
||||
aria-label="Copiar código"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
<pre className="overflow-auto p-3">
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
h1: ({ children }: any) => (
|
||||
<h1 className="text-xl font-bold mt-3 mb-1">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: any) => (
|
||||
<h2 className="text-lg font-semibold mt-2 mb-1">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: any) => (
|
||||
<h3 className="text-base font-semibold mt-2 mb-1">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: any) => (
|
||||
<h4 className="text-sm font-semibold mt-1 mb-1">{children}</h4>
|
||||
),
|
||||
h5: ({ children }: any) => (
|
||||
<h5 className="text-sm font-medium mt-1 mb-1">{children}</h5>
|
||||
),
|
||||
h6: ({ children }: any) => (
|
||||
<h6 className="text-xs font-medium mt-1 mb-1">{children}</h6>
|
||||
),
|
||||
p: ({ children }: any) => <p className="mb-1">{children}</p>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Edit3, RefreshCw, Check } from "lucide-react";
|
||||
import { Copy, Edit3, RefreshCw, Check, ChevronDown } from "lucide-react";
|
||||
import { useCopyPrefs } from "@/hooks/useCopyPrefs";
|
||||
import { stripMarkdown } from "@/hooks/useMarkdown";
|
||||
|
||||
type MessageActionsProps = {
|
||||
content: string;
|
||||
disabled?: boolean;
|
||||
onEdit?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
extractCodeBlocks?: (
|
||||
text: string
|
||||
) => Array<{ language: string; code: string }>;
|
||||
};
|
||||
|
||||
export function MessageActions({
|
||||
@@ -18,70 +17,69 @@ export function MessageActions({
|
||||
disabled,
|
||||
onEdit,
|
||||
onRegenerate,
|
||||
extractCodeBlocks,
|
||||
}: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState<"none" | "markdown" | "text" | "code">(
|
||||
"none"
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const { copyMode, setCopyMode } = useCopyPrefs();
|
||||
|
||||
const copy = async (text: string, flag: MessageActionsProps["content"]) => {
|
||||
const copy = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(flag as any);
|
||||
setTimeout(() => setCopied("none"), 1500);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const stripMarkdown = (md: string) =>
|
||||
md
|
||||
.replace(/```[\s\S]*?```/g, (block) =>
|
||||
block.replace(/```[\s\S]*?\n/, "").replace(/```$/, "")
|
||||
)
|
||||
.replace(/[#*_>`~-]/g, "");
|
||||
const codeBlocks = extractCodeBlocks ? extractCodeBlocks(content) : [];
|
||||
const handleCopyDefault = () => {
|
||||
const textToCopy =
|
||||
copyMode === "markdown" ? content : stripMarkdown(content);
|
||||
copy(textToCopy);
|
||||
};
|
||||
|
||||
const handleModeSelect = (mode: "markdown" | "text") => {
|
||||
setCopyMode(mode);
|
||||
const textToCopy = mode === "markdown" ? content : stripMarkdown(content);
|
||||
copy(textToCopy);
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 opacity-70">
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => copy(content, "markdown")}
|
||||
onClick={handleCopyDefault}
|
||||
disabled={disabled}
|
||||
aria-label="Copy as markdown"
|
||||
aria-label="Copiar"
|
||||
>
|
||||
{copied === "markdown" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}{" "}
|
||||
MD
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => copy(stripMarkdown(content), "text")}
|
||||
disabled={disabled}
|
||||
aria-label="Copy as plain text"
|
||||
>
|
||||
{copied === "text" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}{" "}
|
||||
TXT
|
||||
</button>
|
||||
{codeBlocks.map((b, i) => (
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
key={`${b.language}-${i}`}
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => copy(b.code, "code")}
|
||||
className="rounded px-1 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
disabled={disabled}
|
||||
aria-label={`Copy ${b.language} code`}
|
||||
aria-label="Selecionar formato"
|
||||
>
|
||||
{copied === "code" ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}{" "}
|
||||
{b.language}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 z-10 mt-1 rounded border border-[var(--border)] bg-[var(--background)] shadow-lg">
|
||||
<button
|
||||
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => handleModeSelect("markdown")}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => handleModeSelect("text")}
|
||||
>
|
||||
Texto
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onEdit && (
|
||||
<button
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
|
||||
Reference in new issue
Block a user