feat(ollama): enhance model pull with improved error handling, persistence, and UI
- Add catalog metadata lookup for better error messages with hints (e.g., for missing manifests or tags) - Persist downloaded model references in saved remote models for future reuse - Replace searchRemoteModels import with getSavedRemoteModels in search route - Enhance pull progress UI to display download bytes, digest, and fallback message when data is unavailable - Improve error payload with model details, URL, and tags count to guide users toward valid tags or models
This commit is contained in:
1 parent
1545020afe
commit
a8bb7cc22d
33 files changed
+1298
-235
No files matched your search
@@ -4,8 +4,9 @@ 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";
|
||||
import { SettingsModal } from "@/components/chat/SettingsModal";
|
||||
// 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";
|
||||
@@ -37,6 +38,11 @@ export function ChatContainer({
|
||||
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[]>([]);
|
||||
@@ -141,6 +147,16 @@ export function ChatContainer({
|
||||
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}`);
|
||||
@@ -331,15 +347,59 @@ export function ChatContainer({
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
@@ -357,6 +417,110 @@ export function ChatContainer({
|
||||
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) {
|
||||
@@ -420,6 +584,11 @@ export function ChatContainer({
|
||||
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);
|
||||
@@ -431,6 +600,12 @@ export function ChatContainer({
|
||||
}
|
||||
};
|
||||
|
||||
// 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 (
|
||||
@@ -446,17 +621,18 @@ export function ChatContainer({
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={handleSelectModel}
|
||||
onDeleteModel={handleDeleteModel}
|
||||
onPullModel={handlePullModel}
|
||||
onPullModel={openPullDialog}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsModal
|
||||
systemPrompt={systemPrompt}
|
||||
onSystemPromptChange={handleSystemPromptChange}
|
||||
selectedModel={selectedModel}
|
||||
models={models}
|
||||
/>
|
||||
<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}
|
||||
@@ -469,73 +645,145 @@ export function ChatContainer({
|
||||
|
||||
{/* 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 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>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<ChatMessage
|
||||
message={createMessage("assistant", streamingContent)}
|
||||
isStreaming={true}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{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)] bg-[var(--background)]">
|
||||
<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">
|
||||
{/* Active MCP Badges */}
|
||||
<ActiveMCPBadges
|
||||
activeMcps={activeMcps}
|
||||
mcpNames={mcpNames}
|
||||
onRemove={handleRemoveMCP}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* MCP Selector */}
|
||||
<MCPSelector
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{/* Active MCP Badges */}
|
||||
<ActiveMCPBadges
|
||||
activeMcps={activeMcps}
|
||||
onToggleMCP={handleToggleMCP}
|
||||
onClearAll={handleClearAllMCPs}
|
||||
mcpNames={mcpNames}
|
||||
onRemove={handleRemoveMCP}
|
||||
/>
|
||||
|
||||
<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" />
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -547,6 +795,14 @@ export function ChatContainer({
|
||||
modelName={modelToPull}
|
||||
onConfirmPull={handlePullModel}
|
||||
/>
|
||||
<MCPManualInstallModal
|
||||
isOpen={showManualInstall}
|
||||
onClose={() => setShowManualInstall(false)}
|
||||
onSuccess={() => {
|
||||
// Fechar e opcionalmente poderíamos sinalizar o MCPSelector a recarregar.
|
||||
setShowManualInstall(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,13 +10,21 @@ 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
|
||||
@@ -28,7 +36,7 @@ export function ChatMessage({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[80%] relative rounded-lg px-4 py-2 ${
|
||||
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)]"
|
||||
@@ -43,7 +51,7 @@ export function ChatMessage({
|
||||
</div>
|
||||
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
||||
{!isUser && !isStreaming && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<div className="absolute bottom-2 right-2 z-10 pointer-events-auto">
|
||||
<MessageActions content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
@@ -122,7 +130,7 @@ export function ChatInput({
|
||||
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}
|
||||
maxLength={2000}
|
||||
|
||||
/>
|
||||
<button
|
||||
type={isStreaming ? "button" : "submit"}
|
||||
|
||||
@@ -25,6 +25,8 @@ export function ConversationSidebar({
|
||||
}: ConversationSidebarProps) {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
@@ -66,21 +68,74 @@ export function ConversationSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConversations = conversations.filter((c) =>
|
||||
c.title.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-64 h-full bg-[var(--surface)] border-r border-[var(--border)] flex flex-col">
|
||||
<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)]">
|
||||
<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={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"
|
||||
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" />
|
||||
Nova Conversa
|
||||
{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">
|
||||
<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...
|
||||
@@ -89,15 +144,19 @@ export function ConversationSidebar({
|
||||
<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>
|
||||
) : (
|
||||
conversations.map((conv) => (
|
||||
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)]/10 text-[var(--accent)]"
|
||||
: "hover:bg-[var(--background)]"
|
||||
? "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" />
|
||||
|
||||
@@ -14,16 +14,19 @@ 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
|
||||
@@ -79,6 +82,19 @@ export function MCPSelector({
|
||||
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 */}
|
||||
@@ -89,7 +105,7 @@ export function MCPSelector({
|
||||
>
|
||||
<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(--primary)] text-[var(--primary-foreground)] text-xs font-medium rounded-full">
|
||||
<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>
|
||||
)}
|
||||
@@ -102,36 +118,60 @@ export function MCPSelector({
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-80 bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-lg z-50 max-h-96 overflow-hidden flex flex-col">
|
||||
<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)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plug className="h-4 w-4" />
|
||||
<span className="font-semibold text-sm">MCP Tools</span>
|
||||
<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>
|
||||
{activeMcps.length > 0 && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="text-xs text-red-500 hover:text-red-600 font-medium"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-[var(--muted-foreground)]">
|
||||
Loading MCPs...
|
||||
<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(--muted-foreground)]">
|
||||
No MCPs installed
|
||||
<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">
|
||||
{availableMcps.map((mcp) => {
|
||||
{filtered.map((mcp) => {
|
||||
const isActive = activeMcps.includes(mcp.mcpId);
|
||||
|
||||
return (
|
||||
@@ -139,7 +179,7 @@ export function MCPSelector({
|
||||
key={mcp.mcpId}
|
||||
className={`p-3 rounded-md mb-2 cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? "bg-[var(--primary)]/10 border border-[var(--primary)]/30"
|
||||
? "bg-[var(--accent)]/10 border border-[var(--accent)]/30"
|
||||
: "hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
onClick={() => handleToggle(mcp.mcpId)}
|
||||
@@ -157,9 +197,9 @@ export function MCPSelector({
|
||||
{mcp.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)] ml-6">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60 ml-6">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{mcp.toolCount} tools available</span>
|
||||
<span>{mcp.toolCount} ferramentas disponíveis</span>
|
||||
</div>
|
||||
{isActive && mcp.tools.length > 0 && (
|
||||
<div className="mt-2 ml-6">
|
||||
@@ -167,7 +207,7 @@ export function MCPSelector({
|
||||
{mcp.tools.slice(0, 3).map((tool, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-[var(--primary)]/20 text-[var(--primary)] rounded"
|
||||
className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/20 text-[var(--accent)] rounded"
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.name}
|
||||
@@ -191,14 +231,21 @@ export function MCPSelector({
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
{activeMcps.length > 0 && (
|
||||
<div className="p-3 border-t border-[var(--border)] bg-[var(--surface)]/50">
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
AI can use {activeMcps.length} MCP
|
||||
{activeMcps.length > 1 ? "s" : ""} to help answer your questions
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,11 @@ type SettingsModalProps = {
|
||||
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({
|
||||
@@ -15,14 +20,20 @@ export function SettingsModal({
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -96,6 +107,38 @@ export function SettingsModal({
|
||||
</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 */}
|
||||
|
||||
Reference in new issue
Block a user