chore: remove deprecated files and configurations

- Deleted .gitignore, biome.json, next.config.ts, package.json, pnpm-lock.yaml, pnpm-workspace.yaml, postcss.config.mjs, README.md, and various API, component, and utility files to clean up the codebase.
- Removed unused database files and migration scripts to streamline the project structure.
- Eliminated obsolete hooks and services to enhance maintainability and reduce clutter.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-18 21:43:11 -03:00
1 parent a8bb7cc22d
commit ad9d5d32d2
119 files changed
-17633

No files matched your search

-808
View File
@@ -1,808 +0,0 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { ModelDropdown } from "@/components/model/ModelDropdown";
import { ModelPullDialog } from "@/components/model/ModelPullDialog";
import { ChatMessage } from "@/components/chat/ChatMessage";
// Settings are controlled globally in header
import { MCPSelector, ActiveMCPBadges } from "@/components/chat/MCPSelector";
import { MCPManualInstallModal } from "@/components/mcp/MCPManualInstallModal";
import { Send, Square } from "lucide-react";
import type { ModelInfo } from "@/lib/models";
import type { Message } from "@/lib/chat";
import { createMessage } from "@/lib/chat";
import { useUserPrefs, buildOllamaOptions } from "@/hooks/useUserPrefs";
interface ChatContainerProps {
models: ModelInfo[];
offline: boolean;
onConversationCreated?: () => void;
currentConversationId?: string | null;
}
export function ChatContainer({
models,
offline,
onConversationCreated,
currentConversationId: propCurrentConversationId,
}: 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);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [showManualInstall, setShowManualInstall] = useState(false);
const [showCommandMenu, setShowCommandMenu] = useState(false);
const [commandQuery, setCommandQuery] = useState("");
const [highlightedCmd, setHighlightedCmd] = useState(0);
// Estado para MCPs ativos
const [activeMcps, setActiveMcps] = useState<string[]>([]);
const [mcpNames, setMcpNames] = useState<Map<string, string>>(new Map());
// Carregar modelo salvo ao iniciar
useEffect(() => {
if (ready && prefs.selectedModel && !selectedModel) {
setSelectedModel(prefs.selectedModel);
// Warmup do modelo em background
fetch(`/api/ollama/warmup?model=${encodeURIComponent(prefs.selectedModel)}`).catch(() => {});
}
}, [ready, prefs.selectedModel]);
// Carregar mensagens quando uma conversa é selecionada
useEffect(() => {
if (propCurrentConversationId) {
loadConversation(propCurrentConversationId);
} else {
// Limpar mensagens quando nenhuma conversa está selecionada
setMessages([]);
setConversationId(null);
}
}, [propCurrentConversationId]);
// Salvar modelo quando selecionado
const handleModelChange = (modelName: string) => {
setSelectedModel(modelName);
update({ selectedModel: modelName }); // Salvar no Redis
// Disparar warmup em background
fetch(`/api/ollama/warmup?model=${encodeURIComponent(modelName)}`).catch(() => {});
};
// Carregar MCPs ativos do localStorage
useEffect(() => {
const saved = localStorage.getItem("ollahub-active-mcps");
if (saved) {
try {
const parsed = JSON.parse(saved);
setActiveMcps(parsed.mcps || []);
setMcpNames(new Map(parsed.names || []));
} catch (e) {
console.error("Error loading active MCPs:", e);
}
}
}, []);
// Salvar MCPs ativos no localStorage quando mudarem
useEffect(() => {
if (activeMcps.length > 0 || mcpNames.size > 0) {
localStorage.setItem(
"ollahub-active-mcps",
JSON.stringify({
mcps: activeMcps,
names: Array.from(mcpNames.entries()),
})
);
}
}, [activeMcps, mcpNames]);
// Handlers para MCP Selector
const handleToggleMCP = useCallback(
async (mcpId: string) => {
setActiveMcps((prev) => {
if (prev.includes(mcpId)) {
return prev.filter((id) => id !== mcpId);
} else {
// Buscar nome do MCP se ainda não temos
if (!mcpNames.has(mcpId)) {
fetch("/api/mcp/available-for-chat")
.then((res) => res.json())
.then((data) => {
if (data.success) {
const mcp = data.mcps.find((m: any) => m.mcpId === mcpId);
if (mcp) {
setMcpNames((prev) => new Map(prev).set(mcpId, mcp.name));
}
}
})
.catch(console.error);
}
return [...prev, mcpId];
}
});
},
[mcpNames]
);
const handleClearAllMCPs = useCallback(() => {
setActiveMcps([]);
}, []);
const handleRemoveMCP = useCallback((mcpId: string) => {
setActiveMcps((prev) => prev.filter((id) => id !== mcpId));
}, []);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent]);
// Auto-expansão do textarea conforme o usuário digita
useEffect(() => {
if (inputRef.current) {
const el = inputRef.current;
el.style.height = "auto";
const maxPx = 160; // ~10rem
el.style.height = `${Math.min(el.scrollHeight, maxPx)}px`;
}
}, [inputContent]);
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];
// Add system prompt if it exists and this is the first user message
const messagesToSend =
systemPrompt && messages.length === 0
? [createMessage("system", systemPrompt), ...newMessages]
: newMessages;
setMessages(newMessages);
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,
prefs.numGpu,
prefs.gpuIndex
);
abortRef.current = new AbortController();
const response = await fetch("/api/ollama/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: selectedModel,
messages: messagesToSend,
options,
system: systemPrompt || undefined,
activeMcps: activeMcps.length > 0 ? activeMcps : undefined,
}),
signal: abortRef.current.signal,
});
if (!response.ok) {
throw new Error("Failed to send message");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
let assistantContent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
// Tool execution indicator
if (data.toolExecution) {
console.log(
`🔧 MCP Tool executed: ${data.toolExecution.mcpId}.${data.toolExecution.toolName}`
);
// Opcional: Mostrar indicador visual
}
// Clear previous response (para prompt engineering)
if (data.clearPrevious) {
assistantContent = "";
setStreamingContent("");
}
if (data.token) {
assistantContent += data.token;
setStreamingContent(assistantContent);
}
if (data.done) {
const assistantMessage = createMessage(
"assistant",
assistantContent
);
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);
}
} catch (e) {
console.error("Error parsing stream:", e);
}
}
}
} catch (error) {
console.error("Chat error:", error);
const errorMessage = createMessage(
"assistant",
"Sorry, I encountered an error. Please try again."
);
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsStreaming(false);
setStreamingContent("");
abortRef.current = null;
}
};
// Parse de comando no submit
const tryHandleSlashCommand = (text: string): boolean => {
if (!text.startsWith("/")) return false;
const [cmd, ...rest] = text.trim().split(/\s+/);
if (cmd === "/clear") {
startNewConversation();
setInputContent("");
return true;
}
if (cmd === "/model") {
const newModel = rest.join(" ");
if (newModel) {
handleSelectModel(newModel);
setInputContent("");
}
return true;
}
return false;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputContent.trim() && !isStreaming) {
if (tryHandleSlashCommand(inputContent.trim())) return;
handleSendMessage(inputContent.trim());
setInputContent("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommandMenu) {
const commands = filteredCommands;
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedCmd((h) => (commands.length ? (h + 1) % commands.length : 0));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedCmd((h) => (commands.length ? (h - 1 + commands.length) % commands.length : 0));
return;
}
if (e.key === "Escape") {
setShowCommandMenu(false);
return;
}
if (e.key === "Enter") {
e.preventDefault();
const cmd = commands[highlightedCmd] || commands[0];
if (cmd) executeCommand(cmd);
return;
}
}
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
};
// Regenerar última resposta do assistente
const handleRegenerate = async () => {
if (isStreaming || messages.length === 0 || !selectedModel) return;
const lastUserIndex = [...messages].map((m) => m.role).lastIndexOf("user");
if (lastUserIndex === -1) return;
const baseMessages = messages.slice(0, lastUserIndex + 1);
setMessages(baseMessages);
setIsStreaming(true);
setStreamingContent("");
try {
const options = buildOllamaOptions(
prefs.device,
prefs.numGpu,
prefs.gpuIndex
);
abortRef.current = new AbortController();
const response = await fetch("/api/ollama/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: selectedModel,
messages: baseMessages,
options,
system: systemPrompt || undefined,
activeMcps: activeMcps.length > 0 ? activeMcps : undefined,
}),
signal: abortRef.current.signal,
});
if (!response.ok) throw new Error("Failed to regenerate");
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
let assistantContent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.clearPrevious) {
assistantContent = "";
setStreamingContent("");
}
if (data.token) {
assistantContent += data.token;
setStreamingContent(assistantContent);
}
if (data.done) {
const assistantMessage = createMessage("assistant", assistantContent);
setMessages((prev) => [...prev, assistantMessage]);
setStreamingContent("");
if (conversationId) {
try {
await fetch(`/api/conversations/${conversationId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: "assistant", content: assistantContent }),
});
} catch {}
}
}
if (data.error) throw new Error(data.error);
} catch (e) {
console.error("Error parsing stream:", e);
}
}
}
} catch (error) {
console.error("Regenerate error:", error);
} finally {
setIsStreaming(false);
setStreamingContent("");
abortRef.current = null;
}
};
// Comandos do chat
type ChatCommand = { id: string; label: string; hint?: string };
const availableCommands: ChatCommand[] = [
{ id: "clear", label: "/clear", hint: "Limpar o histórico atual" },
{ id: "model", label: "/model <id>", hint: "Trocar de modelo ativo" },
];
const filteredCommands = availableCommands.filter((c) =>
c.label.toLowerCase().includes(commandQuery.toLowerCase())
);
const executeCommand = (cmd: ChatCommand) => {
if (cmd.id === "clear") {
startNewConversation();
setInputContent("");
setShowCommandMenu(false);
return;
}
if (cmd.id === "model") {
setInputContent("/model ");
setTimeout(() => inputRef.current?.focus(), 0);
return;
}
};
const handleSelectModel = (modelId: string) => {
const model = models.find((m) => m.id === modelId);
if (model) {
handleModelChange(modelId);
// Optional: clear chat when switching models
// setMessages([]);
}
};
const handleDeleteModel = async (modelId: string) => {
try {
const response = await fetch("/api/ollama/delete", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelName: modelId }),
});
if (!response.ok) {
throw new Error("Failed to delete model");
}
// Atualizar lista de modelos após exclusão
// Aqui você pode recarregar a lista ou remover o modelo da lista local
console.log(`Model ${modelId} deleted successfully`);
} catch (error) {
console.error("Error deleting model:", error);
}
};
const handlePullModel = async (modelName: string) => {
try {
const response = await fetch("/api/ollama/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelName }),
});
if (!response.ok) {
throw new Error("Failed to pull model");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === "success") {
// Model pulled successfully, refresh models list
window.location.reload();
} else if (data.error) {
// Mostrar erro amigável quando backend identifica problema com tag/manifest
const hint = data.hint ? `\nHint: ${data.hint}` : "";
const url = data.url ? `\nPágina do modelo: ${data.url}` : "";
alert(`Falha ao baixar o modelo: ${data.detail || data.error}${hint}${url}`);
}
} catch (e) {
console.error("Error parsing pull stream:", e);
}
}
}
} catch (error) {
throw error;
}
};
// Abrir diálogo de instalação (seleção/edição de tag)
const openPullDialog = (name: string) => {
setModelToPull(name);
setShowPullDialog(true);
};
const selectedModelData = models.find((m) => m.id === selectedModel);
return (
<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" : "Select a model to start chatting"}
</h1>
<ModelDropdown
models={models}
selectedModel={selectedModel}
onSelectModel={handleSelectModel}
onDeleteModel={handleDeleteModel}
onPullModel={openPullDialog}
disabled={isStreaming}
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRegenerate}
disabled={isStreaming || messages.length === 0}
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
>
Regenerar
</button>
<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">
<div className={`mx-auto w-full ${
(prefs.chatLayout ?? "compact") === "edge" ? "max-w-full px-2" : "max-w-3xl px-4"
}`}>
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center py-16">
<div className="text-center text-[var(--foreground)]/70">
<p className="text-xl font-medium mb-1">Bem-vindo ao Ollahub</p>
<p className="text-sm">Selecione um modelo e comece a conversar.</p>
</div>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage key={message.id} message={message} bubbleSize={prefs.bubbleSize ?? "md"} />
))}
{isStreaming && streamingContent && (
<ChatMessage
message={createMessage("assistant", streamingContent)}
isStreaming={true}
bubbleSize={prefs.bubbleSize ?? "md"}
/>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
</div>
{/* Input */}
<div className="flex-shrink-0 border-t border-[var(--border)] sticky bottom-0 z-10 bg-[color-mix(in_oklab,var(--background),black_2%)]/80 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_2%)]/60">
<form onSubmit={handleSubmit} className="p-4">
<div className="mx-auto w-full max-w-3xl">
{/* Active MCP Badges */}
<ActiveMCPBadges
activeMcps={activeMcps}
mcpNames={mcpNames}
onRemove={handleRemoveMCP}
/>
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)]/60 shadow-sm transition-all focus-within:ring-2 focus-within:ring-[var(--accent)] relative">
<div className="flex gap-2 p-2">
{/* MCP Selector */}
<MCPSelector
activeMcps={activeMcps}
onToggleMCP={handleToggleMCP}
onClearAll={handleClearAllMCPs}
onOpenInstallModal={() => setShowManualInstall(true)}
/>
<textarea
ref={inputRef}
value={inputContent}
onChange={(e) => {
const val = e.target.value;
setInputContent(val);
if (val.startsWith("/")) {
setShowCommandMenu(true);
const q = val.slice(1).trim();
setCommandQuery(q);
setHighlightedCmd(0);
} else {
setShowCommandMenu(false);
}
}}
onKeyDown={handleKeyDown}
placeholder={
selectedModel ? "Digite uma mensagem..." : "Selecione um modelo primeiro..."
}
disabled={!selectedModel || isStreaming}
className="flex-1 resize-none rounded-lg border-none bg-transparent px-3 py-3 text-sm focus:outline-none disabled:opacity-50 max-h-40 overflow-y-auto"
rows={1}
/>
<button
type={isStreaming ? "button" : "submit"}
onClick={isStreaming ? handleStop : undefined}
disabled={
isStreaming ? false : !inputContent.trim() || !selectedModel
}
className="flex h-10 w-10 my-auto 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>
<div className="flex items-center gap-2 px-3 pb-2 pt-0 text-xs text-[var(--foreground)]/60">
<button
type="button"
onClick={() => navigator.clipboard.writeText(inputContent).catch(() => {})}
className="px-2 py-1 rounded hover:bg-[var(--surface)]"
>
Copiar
</button>
<button
type="button"
onClick={() => setInputContent("")}
className="px-2 py-1 rounded hover:bg-[var(--surface)]"
>
Limpar
</button>
<button
type="button"
onClick={() => {
setInputContent("/");
setShowCommandMenu(true);
setCommandQuery("");
setHighlightedCmd(0);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="px-2 py-1 rounded hover:bg-[var(--surface)]"
>
Comandos
</button>
<span className="ml-auto">{selectedModel ? "" : "Selecione um modelo para começar"}</span>
</div>
{showCommandMenu && filteredCommands.length > 0 && (
<div className="absolute left-2 right-2 bottom-14 rounded-md border border-[var(--border)] bg-[var(--background)] shadow-md overflow-hidden">
{filteredCommands.map((cmd, idx) => (
<button
key={cmd.id + idx}
type="button"
onClick={() => executeCommand(cmd)}
className={`w-full text-left px-3 py-2 text-sm hover:bg-[var(--surface)] ${
idx === highlightedCmd ? "bg-[var(--surface)]" : ""
}`}
>
<div className="font-medium">{cmd.label}</div>
{cmd.hint && (
<div className="text-[10px] opacity-70">{cmd.hint}</div>
)}
</button>
))}
</div>
)}
</div>
</div>
</form>
</div>
{/* Pull Dialog */}
<ModelPullDialog
isOpen={showPullDialog}
onClose={() => setShowPullDialog(false)}
modelName={modelToPull}
onConfirmPull={handlePullModel}
/>
<MCPManualInstallModal
isOpen={showManualInstall}
onClose={() => setShowManualInstall(false)}
onSuccess={() => {
// Fechar e opcionalmente poderíamos sinalizar o MCPSelector a recarregar.
setShowManualInstall(false);
}}
/>
</div>
);
}
-165
View File
@@ -1,165 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { User, Bot, Send, Loader2, Square } from "lucide-react";
import { MarkdownRenderer } from "@/components/message/MarkdownRenderer";
import { MessageActions } from "@/components/message/MessageActions";
import type { Message } from "@/lib/chat";
import { formatTimestamp } from "@/lib/chat";
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
bubbleSize?: "sm" | "md" | "lg";
}
export function ChatMessage({
message,
isStreaming = false,
bubbleSize = "md",
}: ChatMessageProps) {
const isUser = message.role === "user";
const sizeClass =
bubbleSize === "sm"
? "px-3 py-1.5 text-sm"
: bubbleSize === "lg"
? "px-5 py-3 text-base"
: "px-4 py-2 text-sm";
return (
<div
className={`flex gap-3 p-4 ${isUser ? "justify-end" : "justify-start"}`}
>
{!isUser && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--accent)] text-white">
<Bot className="h-4 w-4" />
</div>
)}
<div
className={`max-w-[85%] md:max-w-[80%] relative rounded-lg ${sizeClass} shadow-sm ${
isUser
? "bg-[var(--accent)] text-white"
: "bg-[var(--surface)] text-[var(--foreground)]"
}`}
>
<div className="break-words">
{isUser ? (
<div className="whitespace-pre-wrap">{message.content}</div>
) : (
<MarkdownRenderer content={message.content} />
)}
</div>
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
{!isUser && !isStreaming && (
<div className="absolute bottom-2 right-2 z-10 pointer-events-auto">
<MessageActions content={message.content} />
</div>
)}
<div
className={`mt-1 text-xs opacity-70 ${
isUser ? "text-right" : "text-left"
}`}
>
{formatTimestamp(message.timestamp)}
</div>
</div>
{isUser && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--surface)]">
<User className="h-4 w-4" />
</div>
)}
</div>
);
}
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("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (content.trim() && !disabled) {
onSendMessage(content.trim());
setContent("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${Math.min(
textareaRef.current.scrollHeight,
120
)}px`;
}
}, [content]);
return (
<form
onSubmit={handleSubmit}
className="flex gap-2 p-4 border-t border-[var(--border)]"
>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
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}
/>
<button
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"
>
{isStreaming ? (
<Square className="h-4 w-4" />
) : disabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</form>
);
}
interface StreamingTextProps {
text: string;
isStreaming: boolean;
}
export function StreamingText({ text, isStreaming }: StreamingTextProps) {
return (
<span>
{text}
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
</span>
);
}
-176
View File
@@ -1,176 +0,0 @@
"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);
const [creating, setCreating] = useState(false);
const [query, setQuery] = useState("");
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);
}
};
const filteredConversations = conversations.filter((c) =>
c.title.toLowerCase().includes(query.toLowerCase())
);
return (
<div className="w-64 lg:w-72 h-full bg-[var(--surface)] border-r border-[var(--border)] flex flex-col">
{/* Header */}
<div className="p-4 border-b border-[var(--border)] sticky top-0 z-10 bg-[color-mix(in_oklab,var(--surface),black_2%)]/90 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--surface),black_2%)]/70">
<button
onClick={async () => {
if (creating) return;
setCreating(true);
try {
// Buscar modelo selecionado nas preferências
const prefsRes = await fetch("/api/preferences");
const prefs = await prefsRes.json();
const model = prefs?.selectedModel;
if (!model) {
alert("Selecione um modelo antes de criar uma conversa.");
return;
}
// Criar conversa vazia (título padrão gerado no backend)
const res = await fetch("/api/conversations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.ok) throw new Error("Falha ao criar conversa");
const { id, title } = await res.json();
// Inserir no topo da lista e selecionar
const newConv: Conversation = {
id,
title,
model,
updated_at: Date.now(),
};
setConversations((prev) => [newConv, ...prev]);
onSelectConversation(id);
// Notificar container para possíveis efeitos colaterais
onNewConversation();
} catch (e) {
console.error(e);
alert("Não foi possível criar a conversa.");
} finally {
setCreating(false);
}
}}
disabled={creating}
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 disabled:opacity-60"
>
<Plus className="h-4 w-4" />
{creating ? "Criando..." : "Nova Conversa"}
</button>
<div className="mt-3">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar conversa..."
className="w-full px-3 py-2 text-sm rounded-md bg-[var(--background)] border border-[var(--border)] outline-none focus:border-[var(--accent)] transition-colors"
/>
</div>
</div>
{/* Lista de Conversas */}
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar">
{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>
) : filteredConversations.length === 0 ? (
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
Nenhum resultado para "{query}"
</div>
) : (
filteredConversations.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)]/12 text-[var(--accent)] ring-1 ring-[var(--accent)]/30"
: "hover:bg-[color-mix(in_oklab,var(--surface),black_6%)]"
}`}
>
<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>
);
}
-293
View File
@@ -1,293 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Plug, ChevronDown, X, Zap } from "lucide-react";
interface MCPForChat {
mcpId: string;
name: string;
toolCount: number;
tools: Array<{ name: string; description: string }>;
}
interface MCPSelectorProps {
activeMcps: string[];
onToggleMCP: (mcpId: string) => void;
onClearAll: () => void;
onOpenInstallModal?: () => void;
}
export function MCPSelector({
activeMcps,
onToggleMCP,
onClearAll,
onOpenInstallModal,
}: MCPSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [availableMcps, setAvailableMcps] = useState<MCPForChat[]>([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
// Carregar MCPs disponíveis
useEffect(() => {
if (isOpen) {
loadAvailableMCPs();
}
}, [isOpen]);
// Fechar dropdown ao clicar fora
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [isOpen]);
const loadAvailableMCPs = async () => {
setLoading(true);
try {
const response = await fetch("/api/mcp/available-for-chat");
const data = await response.json();
if (data.success) {
setAvailableMcps(
data.mcps.map((mcp: any) => ({
mcpId: mcp.mcpId,
name: mcp.name,
toolCount: mcp.toolCount,
tools: mcp.rawTools || [],
}))
);
}
} catch (error) {
console.error("Error loading available MCPs:", error);
} finally {
setLoading(false);
}
};
const handleToggle = (mcpId: string) => {
onToggleMCP(mcpId);
};
const filtered = availableMcps.filter((mcp) => {
if (!query.trim()) return true;
const q = query.toLowerCase();
return (
mcp.name.toLowerCase().includes(q) ||
mcp.tools.some(
(t) =>
t.name.toLowerCase().includes(q) ||
(t.description || "").toLowerCase().includes(q)
)
);
});
return (
<div className="relative" ref={dropdownRef}>
{/* Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface)] transition-colors"
title="Manage MCP Tools"
>
<Plug className="h-4 w-4" />
{activeMcps.length > 0 && (
<span className="flex items-center justify-center h-5 min-w-[20px] px-1.5 bg-[var(--accent)] text-[var(--accent-foreground)] text-xs font-medium rounded-full">
{activeMcps.length}
</span>
)}
<ChevronDown
className={`h-4 w-4 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute bottom-full left-0 mb-2 w-96 bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-lg z-50 max-h-[28rem] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-3 border-b border-[var(--border)] sticky top-0 bg-[color-mix(in_oklab,var(--background),black_2%)]/90 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_2%)]/70">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Plug className="h-4 w-4" />
<span className="font-semibold text-sm">MCP Tools</span>
</div>
{activeMcps.length > 0 && (
<button
onClick={onClearAll}
className="text-xs px-2 py-1 rounded-md border border-[var(--border)] hover:bg-[var(--surface)]"
>
Limpar tudo
</button>
)}
</div>
<div className="mt-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar por servidor ou ferramenta..."
className="w-full px-3 py-2 text-sm rounded-md bg-[var(--background)] border border-[var(--border)] outline-none focus:border-[var(--accent)] transition-colors"
/>
</div>
</div>
{/* Content */}
<div className="overflow-y-auto flex-1">
{loading ? (
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
Carregando MCPs...
</div>
) : availableMcps.length === 0 ? (
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
Nenhum MCP instalado
{onOpenInstallModal && (
<div className="mt-2">
<button
onClick={onOpenInstallModal}
className="text-[var(--accent)] hover:underline"
>
Instalar manualmente
</button>
</div>
)}
</div>
) : filtered.length === 0 ? (
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
Nenhum resultado para "{query}"
</div>
) : (
<div className="p-2">
{filtered.map((mcp) => {
const isActive = activeMcps.includes(mcp.mcpId);
return (
<div
key={mcp.mcpId}
className={`p-3 rounded-md mb-2 cursor-pointer transition-colors ${
isActive
? "bg-[var(--accent)]/10 border border-[var(--accent)]/30"
: "hover:bg-[var(--surface)]"
}`}
onClick={() => handleToggle(mcp.mcpId)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<input
type="checkbox"
checked={isActive}
onChange={() => {}}
className="rounded"
/>
<span className="font-medium text-sm">
{mcp.name}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60 ml-6">
<Zap className="h-3 w-3" />
<span>{mcp.toolCount} ferramentas disponíveis</span>
</div>
{isActive && mcp.tools.length > 0 && (
<div className="mt-2 ml-6">
<div className="flex flex-wrap gap-1">
{mcp.tools.slice(0, 3).map((tool, idx) => (
<span
key={idx}
className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/20 text-[var(--accent)] rounded"
title={tool.description}
>
{tool.name}
</span>
))}
{mcp.tools.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 bg-[var(--surface)] rounded">
+{mcp.tools.length - 3}
</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Footer info */}
<div className="p-3 border-t border-[var(--border)] bg-[var(--surface)]/50 flex items-center justify-between">
<p className="text-xs text-[var(--foreground)]/60">
{activeMcps.length > 0
? `${activeMcps.length} MCPs ativos para enriquecer as respostas`
: "Nenhum MCP ativo"}
</p>
{onOpenInstallModal && (
<button
onClick={onOpenInstallModal}
className="text-xs px-2 py-1 rounded-md border border-[var(--border)] hover:bg-[var(--surface)]"
>
Instalar MCP manualmente
</button>
)}
</div>
</div>
)}
</div>
);
}
/**
* Pills/badges component to show active MCPs in the chat input area
*/
interface ActiveMCPBadgesProps {
activeMcps: string[];
mcpNames: Map<string, string>;
onRemove: (mcpId: string) => void;
}
export function ActiveMCPBadges({
activeMcps,
mcpNames,
onRemove,
}: ActiveMCPBadgesProps) {
if (activeMcps.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5 mb-2">
{activeMcps.map((mcpId) => (
<div
key={mcpId}
className="flex items-center gap-1 px-2 py-1 bg-[var(--primary)]/10 text-[var(--primary)] text-xs rounded-md border border-[var(--primary)]/30"
>
<Zap className="h-3 w-3" />
<span className="font-medium">{mcpNames.get(mcpId) || mcpId}</span>
<button
onClick={(e) => {
e.stopPropagation();
onRemove(mcpId);
}}
className="ml-1 hover:bg-[var(--primary)]/20 rounded"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
);
}
-164
View File
@@ -1,164 +0,0 @@
"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 }>;
design?: {
chatLayout: "compact" | "edge";
bubbleSize: "sm" | "md" | "lg";
onChange: (next: Partial<{ chatLayout: "compact" | "edge"; bubbleSize: "sm" | "md" | "lg" }>) => void;
};
};
export function SettingsModal({
systemPrompt,
onSystemPromptChange,
selectedModel,
models,
design,
}: SettingsModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [tempPrompt, setTempPrompt] = useState(systemPrompt);
const [tempChatLayout, setTempChatLayout] = useState<"compact" | "edge">(design?.chatLayout ?? "compact");
const [tempBubbleSize, setTempBubbleSize] = useState<"sm" | "md" | "lg">(design?.bubbleSize ?? "md");
const selectedModelData = models.find((m) => m.id === selectedModel);
const handleSave = () => {
onSystemPromptChange(tempPrompt);
if (design?.onChange) {
design.onChange({ chatLayout: tempChatLayout, bubbleSize: tempBubbleSize });
}
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>
{/* Preferências de Design */}
<div className="space-y-3">
<h3 className="font-medium">Preferências de Design</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-2">
<label className="text-sm text-[var(--foreground)]/80">Layout do Chat</label>
<select
value={tempChatLayout}
onChange={(e) => setTempChatLayout(e.target.value as any)}
className="w-full p-2 rounded-lg border border-[var(--border)] bg-[var(--background)]"
>
<option value="compact">Compacto (centralizado)</option>
<option value="edge">Até as bordas (edge-to-edge)</option>
</select>
<p className="text-xs text-[var(--foreground)]/60">Controla se o conteúdo do chat é centralizado com largura máxima ou ocupa toda a largura.</p>
</div>
<div className="space-y-2">
<label className="text-sm text-[var(--foreground)]/80">Tamanho da Bolha</label>
<select
value={tempBubbleSize}
onChange={(e) => setTempBubbleSize(e.target.value as any)}
className="w-full p-2 rounded-lg border border-[var(--border)] bg-[var(--background)]"
>
<option value="sm">Pequena</option>
<option value="md">Média</option>
<option value="lg">Grande</option>
</select>
<p className="text-xs text-[var(--foreground)]/60">Ajusta paddings e tipografia das bolhas de mensagem.</p>
</div>
</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>
)}
</>
);
}
-111
View File
@@ -1,111 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Settings, Save, RotateCcw } from "lucide-react";
import { SystemPromptInfo } from "./SystemPromptInfo";
interface SystemPromptProps {
onSystemPromptChange: (prompt: string) => void;
}
export function SystemPrompt({ onSystemPromptChange }: SystemPromptProps) {
const [systemPrompt, setSystemPrompt] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem("ollahub-system-prompt");
if (saved) {
setSystemPrompt(saved);
onSystemPromptChange(saved);
}
}, [onSystemPromptChange]);
const handleSave = () => {
localStorage.setItem("ollahub-system-prompt", systemPrompt);
onSystemPromptChange(systemPrompt);
setIsEditing(false);
setHasChanges(false);
};
const handleReset = () => {
const defaultPrompt = "";
setSystemPrompt(defaultPrompt);
localStorage.removeItem("ollahub-system-prompt");
onSystemPromptChange(defaultPrompt);
setIsEditing(false);
setHasChanges(false);
};
const handleChange = (value: string) => {
setSystemPrompt(value);
setHasChanges(true);
setIsEditing(true);
};
return (
<div className="mt-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-[var(--foreground)]/80">
System Prompt
</h3>
<div className="flex gap-1">
<SystemPromptInfo />
{isEditing && (
<>
<button
onClick={handleSave}
disabled={!hasChanges}
className="p-1 rounded hover:bg-[var(--background)] disabled:opacity-50"
title="Save prompt"
>
<Save className="h-3 w-3" />
</button>
<button
onClick={handleReset}
className="p-1 rounded hover:bg-[var(--background)]"
title="Reset to default"
>
<RotateCcw className="h-3 w-3" />
</button>
</>
)}
<button
onClick={() => setIsEditing(!isEditing)}
className="p-1 rounded hover:bg-[var(--background)]"
title="Edit system prompt"
>
<Settings className="h-3 w-3" />
</button>
</div>
</div>
{isEditing ? (
<div className="space-y-2">
<textarea
value={systemPrompt}
onChange={(e) => handleChange(e.target.value)}
placeholder="Enter system prompt (optional)..."
className="w-full h-24 resize-none rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
<p className="text-xs text-[var(--foreground)]/60">
System prompts help define the AI's behavior and personality.
</p>
</div>
) : (
<div className="rounded-md border border-[var(--border)] bg-[var(--background)] p-3">
{systemPrompt ? (
<p className="text-xs text-[var(--foreground)]/80 whitespace-pre-wrap">
{systemPrompt}
</p>
) : (
<p className="text-xs text-[var(--foreground)]/60 italic">
No system prompt set
</p>
)}
</div>
)}
</div>
);
}
-50
View File
@@ -1,50 +0,0 @@
"use client";
import { Info } from "lucide-react";
import { useState } from "react";
export function SystemPromptInfo() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="p-1 rounded hover:bg-[var(--background)]"
title="About system prompts"
>
<Info className="h-3 w-3" />
</button>
{isOpen && (
<div className="absolute top-6 left-0 z-50 w-80 rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 shadow-lg">
<div className="text-xs space-y-2">
<h4 className="font-medium">System Prompts</h4>
<p>
System prompts define the AI's behavior, personality, and response
style. They are sent once at the beginning of each conversation.
</p>
<div className="space-y-1">
<p className="font-medium">Examples:</p>
<ul className="list-disc list-inside space-y-1 text-[var(--foreground)]/80">
<li>"You are a helpful coding assistant"</li>
<li>"Respond in Portuguese, be concise"</li>
<li>"You are a creative writing coach"</li>
</ul>
</div>
<p className="text-[var(--foreground)]/60">
Based on Ollama documentation - system messages help guide model
behavior.
</p>
</div>
<button
onClick={() => setIsOpen(false)}
className="absolute top-2 right-2 p-1 rounded hover:bg-[var(--surface)]"
>
×
</button>
</div>
)}
</div>
);
}