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
@@ -1,5 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { pullModel, ensureOllamaAvailable } from "@/lib/ollama";
|
import { pullModel, ensureOllamaAvailable } from "@/lib/ollama";
|
||||||
|
import { getCatalogModels } from "@/lib/catalog";
|
||||||
|
import { addSavedRemoteModel } from "@/lib/user-remote-models";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -26,6 +28,11 @@ export async function POST(req: NextRequest) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buscar metadados do catálogo para melhorar mensagens de erro
|
||||||
|
const baseName = String(model).split(":")[0];
|
||||||
|
const catalog = await getCatalogModels();
|
||||||
|
const catalogEntry = catalog.find((m) => m.name === baseName);
|
||||||
|
|
||||||
const response = await pullModel(model);
|
const response = await pullModel(model);
|
||||||
|
|
||||||
for await (const chunk of response) {
|
for await (const chunk of response) {
|
||||||
@@ -34,18 +41,59 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persistir referência do modelo baixado para reutilização futura
|
||||||
|
try {
|
||||||
|
await addSavedRemoteModel({ name: String(model), installed: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(
|
||||||
JSON.stringify({ status: "success" }) + "\n",
|
JSON.stringify({ status: "success" }) + "\n",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
controller.close();
|
controller.close();
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("Pull streaming error:", error);
|
console.error("Pull streaming error:", error);
|
||||||
|
const message = typeof error?.message === "string" ? error.message : "Failed to pull model";
|
||||||
|
const baseName = String(model).split(":")[0];
|
||||||
|
let hint: string | undefined;
|
||||||
|
|
||||||
|
// Recarregar entrada de catálogo para enriquecer erro
|
||||||
|
let catalogEntry: { url?: string; tags_count?: number } | undefined;
|
||||||
|
try {
|
||||||
|
const catalog = await getCatalogModels();
|
||||||
|
catalogEntry = catalog.find((m) => m.name === baseName);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Heurísticas: manifest não encontrado → provavelmente falta tag válida
|
||||||
|
if (/manifest/i.test(message) || /does not exist/i.test(message)) {
|
||||||
|
hint = "Modelo/tag não encontrado no registro. Selecione uma tag válida ou verifique a página do modelo.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
error: "Failed to pull model",
|
||||||
|
detail: message,
|
||||||
|
model,
|
||||||
|
name: baseName,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Anexar URL do catálogo quando disponível
|
||||||
|
if (catalogEntry?.url) {
|
||||||
|
payload.url = catalogEntry.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se usuário não especificou tag (sem ":") e sabemos que há múltiplas tags
|
||||||
|
if (!String(model).includes(":") && typeof catalogEntry?.tags_count === "number") {
|
||||||
|
payload.tags_count = catalogEntry.tags_count;
|
||||||
|
if (!hint && catalogEntry.tags_count > 0) {
|
||||||
|
hint = "Este modelo pode não ter a tag 'latest'. Escolha uma tag específica.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hint) payload.hint = hint;
|
||||||
|
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
new TextEncoder().encode(
|
new TextEncoder().encode(JSON.stringify(payload) + "\n"),
|
||||||
JSON.stringify({ error: "Failed to pull model" }) + "\n",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
controller.close();
|
controller.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { searchRemoteModels } from "@/lib/ollama";
|
import { getSavedRemoteModels } from "@/lib/user-remote-models";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const query = searchParams.get("q") || undefined;
|
const query = searchParams.get("q") || undefined;
|
||||||
|
|
||||||
const remoteModels = await searchRemoteModels(query);
|
const saved = await getSavedRemoteModels();
|
||||||
|
const q = (query || "").trim().toLowerCase();
|
||||||
|
const filtered = q
|
||||||
|
? saved.filter((m) => m.name.toLowerCase().includes(q))
|
||||||
|
: saved;
|
||||||
|
|
||||||
return NextResponse.json({
|
const models = filtered.map((m) => ({
|
||||||
success: true,
|
name: m.name,
|
||||||
models: remoteModels,
|
description: m.description,
|
||||||
});
|
url: m.url,
|
||||||
|
tags_count: m.tags_count,
|
||||||
|
installed: m.installed === true,
|
||||||
|
remote: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, models });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error searching remote models:", error);
|
console.error("Error searching remote models:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCatalogModels } from "@/lib/catalog";
|
||||||
|
|
||||||
|
// Cache simples em memória para evitar múltiplos scrapes por nome
|
||||||
|
const TAGS_CACHE = new Map<string, { tags: string[]; expiresAt: number }>();
|
||||||
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h
|
||||||
|
|
||||||
|
async function fetchTagsFromPage(url: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
// Heurística best-effort: procurar padrões de tag na página
|
||||||
|
// Exemplos possíveis: ":7b", ":8b", ":q4_K_S", etc.
|
||||||
|
// Evitar listas enormes ou falsas-positivas: limitar tamanho e caracteres
|
||||||
|
const tagRegex = /:\s*([A-Za-z0-9._-]{1,32})/g;
|
||||||
|
const found = new Set<string>();
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = tagRegex.exec(html)) !== null) {
|
||||||
|
const tag = match[1];
|
||||||
|
if (tag && !/^(http|https)$/i.test(tag)) {
|
||||||
|
found.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retornar até 50 tags para não sobrecarregar UI
|
||||||
|
return Array.from(found).slice(0, 50);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const name = (searchParams.get("name") || "").trim();
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing name" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
const cached = TAGS_CACHE.get(name);
|
||||||
|
const now = Date.now();
|
||||||
|
if (cached && cached.expiresAt > now) {
|
||||||
|
return NextResponse.json({ success: true, name, tags: cached.tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await getCatalogModels();
|
||||||
|
const entry = catalog.find((m) => m.name === name);
|
||||||
|
const url = entry?.url;
|
||||||
|
|
||||||
|
// Sem URL conhecida → não conseguimos buscar tags no registro
|
||||||
|
if (!url) {
|
||||||
|
TAGS_CACHE.set(name, { tags: [], expiresAt: now + CACHE_TTL_MS });
|
||||||
|
return NextResponse.json({ success: true, name, tags: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = await fetchTagsFromPage(url);
|
||||||
|
TAGS_CACHE.set(name, { tags, expiresAt: now + CACHE_TTL_MS });
|
||||||
|
return NextResponse.json({ success: true, name, url, tags });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching model tags:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch tags" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import os from "os";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const totalMem = os.totalmem();
|
||||||
|
const freeMem = os.freemem();
|
||||||
|
const usedMem = totalMem - freeMem;
|
||||||
|
const load = os.loadavg();
|
||||||
|
const memUsage = process.memoryUsage();
|
||||||
|
|
||||||
|
// Base com os/fallback
|
||||||
|
const baseInfo: any = {
|
||||||
|
os: {
|
||||||
|
platform: os.platform(),
|
||||||
|
arch: os.arch(),
|
||||||
|
release: os.release(),
|
||||||
|
uptimeSec: os.uptime(),
|
||||||
|
},
|
||||||
|
cpu: {
|
||||||
|
model: os.cpus()?.[0]?.model || "unknown",
|
||||||
|
cores: os.cpus()?.length || 0,
|
||||||
|
speed: os.cpus()?.[0]?.speed || 0,
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
total: totalMem,
|
||||||
|
free: freeMem,
|
||||||
|
used: usedMem,
|
||||||
|
modules: [] as Array<{ size: number; clockSpeed?: number; type?: string }>,
|
||||||
|
},
|
||||||
|
gpus: [] as Array<{ index: number; name: string; vendor?: string; vramMB?: number; bus?: string }>,
|
||||||
|
metrics: {
|
||||||
|
loadAvg: { "1m": load[0] ?? 0, "5m": load[1] ?? 0, "15m": load[2] ?? 0 },
|
||||||
|
process: {
|
||||||
|
rss: memUsage.rss,
|
||||||
|
heapUsed: memUsage.heapUsed,
|
||||||
|
heapTotal: memUsage.heapTotal,
|
||||||
|
external: memUsage.external,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tentar usar systeminformation, se disponível
|
||||||
|
try {
|
||||||
|
// Import dinâmico sem acionar o bundler (evita Module not found)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||||
|
const dynImport: (m: string) => Promise<any> = new Function(
|
||||||
|
"m",
|
||||||
|
"return import(m)"
|
||||||
|
) as any;
|
||||||
|
const si = await dynImport("systeminformation");
|
||||||
|
|
||||||
|
const [cpu, osInfo, memLayout, graphics, currentLoad] = await Promise.all([
|
||||||
|
si.cpu(),
|
||||||
|
si.osInfo(),
|
||||||
|
si.memLayout(),
|
||||||
|
si.graphics(),
|
||||||
|
si.currentLoad(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
baseInfo.os = {
|
||||||
|
platform: osInfo.platform || baseInfo.os.platform,
|
||||||
|
arch: os.arch(),
|
||||||
|
release: osInfo.release || baseInfo.os.release,
|
||||||
|
uptimeSec: baseInfo.os.uptimeSec,
|
||||||
|
distro: osInfo.distro,
|
||||||
|
};
|
||||||
|
baseInfo.cpu = {
|
||||||
|
model: cpu.brand || baseInfo.cpu.model,
|
||||||
|
cores: cpu.cores || baseInfo.cpu.cores,
|
||||||
|
physicalCores: cpu.physicalCores,
|
||||||
|
speed: Number(cpu.speed) || baseInfo.cpu.speed,
|
||||||
|
};
|
||||||
|
baseInfo.memory.modules = (memLayout || []).map((m: any) => ({ size: m.size, clockSpeed: m.clockSpeed, type: m.type }));
|
||||||
|
baseInfo.gpus = (graphics?.controllers || []).map((c: any, idx: number) => ({ index: idx, name: c.model || c.vendor || "GPU", vendor: c.vendor, vramMB: c.vram, bus: c.bus }));
|
||||||
|
baseInfo.metrics.loadAvg = {
|
||||||
|
"1m": currentLoad?.avgload ?? baseInfo.metrics.loadAvg["1m"],
|
||||||
|
"5m": baseInfo.metrics.loadAvg["5m"],
|
||||||
|
"15m": baseInfo.metrics.loadAvg["15m"],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// sem systeminformation, seguimos com fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback adicional para detectar GPUs no Windows via PowerShell
|
||||||
|
try {
|
||||||
|
if ((!baseInfo.gpus || baseInfo.gpus.length === 0) && (baseInfo.os.platform === "win32" || os.platform() === "win32")) {
|
||||||
|
const { execFileSync } = await import("child_process");
|
||||||
|
const out = execFileSync("powershell", [
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM,PNPDeviceID | ConvertTo-Json -Depth 3"
|
||||||
|
], { encoding: "utf8", timeout: 2000 });
|
||||||
|
const parsed = JSON.parse(out);
|
||||||
|
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
baseInfo.gpus = arr.map((g: any, idx: number) => ({ index: idx, name: g.Name || "GPU", vendor: undefined, vramMB: g.AdapterRAM ? Math.round(g.AdapterRAM / 1024 / 1024) : undefined, bus: g.PNPDeviceID }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore fallback errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, info: baseInfo });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ success: false, error: String(e) }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
SettingsButton,
|
SettingsButton,
|
||||||
SettingsModal,
|
SettingsModal,
|
||||||
} from "@/components/settings/SettingsModal";
|
} from "@/components/settings/SettingsModal";
|
||||||
import { MCPManualInstallModal } from "@/components/mcp/MCPManualInstallModal";
|
|
||||||
import { Store } from "lucide-react";
|
import { Store } from "lucide-react";
|
||||||
|
|
||||||
interface ClientHomeProps {
|
interface ClientHomeProps {
|
||||||
@@ -22,7 +21,7 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
|
|||||||
const [currentConversationId, setCurrentConversationId] = useState<
|
const [currentConversationId, setCurrentConversationId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [showManualInstall, setShowManualInstall] = useState(false);
|
|
||||||
|
|
||||||
const handleConversationCreated = () => {
|
const handleConversationCreated = () => {
|
||||||
// Incrementar trigger para forçar atualização da sidebar
|
// Incrementar trigger para forçar atualização da sidebar
|
||||||
@@ -48,13 +47,6 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
|
|||||||
Ollama offline
|
Ollama offline
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={() => setShowManualInstall(true)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors text-sm"
|
|
||||||
>
|
|
||||||
<Store className="h-4 w-4" />
|
|
||||||
Install MCP Manually
|
|
||||||
</button>
|
|
||||||
<SettingsButton onOpen={() => setOpen(true)} />
|
<SettingsButton onOpen={() => setOpen(true)} />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
@@ -77,10 +69,7 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
|
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
|
||||||
<MCPManualInstallModal
|
|
||||||
isOpen={showManualInstall}
|
|
||||||
onClose={() => setShowManualInstall(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,9 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import { ModelDropdown } from "@/components/model/ModelDropdown";
|
import { ModelDropdown } from "@/components/model/ModelDropdown";
|
||||||
import { ModelPullDialog } from "@/components/model/ModelPullDialog";
|
import { ModelPullDialog } from "@/components/model/ModelPullDialog";
|
||||||
import { ChatMessage } from "@/components/chat/ChatMessage";
|
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 { MCPSelector, ActiveMCPBadges } from "@/components/chat/MCPSelector";
|
||||||
|
import { MCPManualInstallModal } from "@/components/mcp/MCPManualInstallModal";
|
||||||
import { Send, Square } from "lucide-react";
|
import { Send, Square } from "lucide-react";
|
||||||
import type { ModelInfo } from "@/lib/models";
|
import type { ModelInfo } from "@/lib/models";
|
||||||
import type { Message } from "@/lib/chat";
|
import type { Message } from "@/lib/chat";
|
||||||
@@ -37,6 +38,11 @@ export function ChatContainer({
|
|||||||
const [modelToPull, setModelToPull] = useState<string>("");
|
const [modelToPull, setModelToPull] = useState<string>("");
|
||||||
const [systemPrompt, setSystemPrompt] = useState<string>("");
|
const [systemPrompt, setSystemPrompt] = useState<string>("");
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
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
|
// Estado para MCPs ativos
|
||||||
const [activeMcps, setActiveMcps] = useState<string[]>([]);
|
const [activeMcps, setActiveMcps] = useState<string[]>([]);
|
||||||
@@ -141,6 +147,16 @@ export function ChatContainer({
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages, streamingContent]);
|
}, [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) => {
|
const loadConversation = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/conversations/${id}`);
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (inputContent.trim() && !isStreaming) {
|
if (inputContent.trim() && !isStreaming) {
|
||||||
|
if (tryHandleSlashCommand(inputContent.trim())) return;
|
||||||
handleSendMessage(inputContent.trim());
|
handleSendMessage(inputContent.trim());
|
||||||
setInputContent("");
|
setInputContent("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
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) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit(e);
|
handleSubmit(e);
|
||||||
@@ -357,6 +417,110 @@ export function ChatContainer({
|
|||||||
update({ systemPrompt: prompt }); // Salvar no SQLite
|
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 handleSelectModel = (modelId: string) => {
|
||||||
const model = models.find((m) => m.id === modelId);
|
const model = models.find((m) => m.id === modelId);
|
||||||
if (model) {
|
if (model) {
|
||||||
@@ -420,6 +584,11 @@ export function ChatContainer({
|
|||||||
if (data.status === "success") {
|
if (data.status === "success") {
|
||||||
// Model pulled successfully, refresh models list
|
// Model pulled successfully, refresh models list
|
||||||
window.location.reload();
|
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) {
|
} catch (e) {
|
||||||
console.error("Error parsing pull stream:", 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);
|
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -446,17 +621,18 @@ export function ChatContainer({
|
|||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelectModel={handleSelectModel}
|
onSelectModel={handleSelectModel}
|
||||||
onDeleteModel={handleDeleteModel}
|
onDeleteModel={handleDeleteModel}
|
||||||
onPullModel={handlePullModel}
|
onPullModel={openPullDialog}
|
||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<SettingsModal
|
<button
|
||||||
systemPrompt={systemPrompt}
|
onClick={handleRegenerate}
|
||||||
onSystemPromptChange={handleSystemPromptChange}
|
disabled={isStreaming || messages.length === 0}
|
||||||
selectedModel={selectedModel}
|
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
|
||||||
models={models}
|
>
|
||||||
/>
|
Regenerar
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
disabled={!isStreaming}
|
disabled={!isStreaming}
|
||||||
@@ -469,73 +645,145 @@ export function ChatContainer({
|
|||||||
|
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
{messages.length === 0 ? (
|
<div className={`mx-auto w-full ${
|
||||||
<div className="flex h-full items-center justify-center">
|
(prefs.chatLayout ?? "compact") === "edge" ? "max-w-full px-2" : "max-w-3xl px-4"
|
||||||
<div className="text-center text-[var(--foreground)]/60">
|
}`}>
|
||||||
<p className="text-lg mb-2">Welcome to Ollahub</p>
|
{messages.length === 0 ? (
|
||||||
<p className="text-sm">Select a model and start chatting!</p>
|
<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>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
{messages.map((message) => (
|
||||||
{messages.map((message) => (
|
<ChatMessage key={message.id} message={message} bubbleSize={prefs.bubbleSize ?? "md"} />
|
||||||
<ChatMessage key={message.id} message={message} />
|
))}
|
||||||
))}
|
{isStreaming && streamingContent && (
|
||||||
{isStreaming && streamingContent && (
|
<ChatMessage
|
||||||
<ChatMessage
|
message={createMessage("assistant", streamingContent)}
|
||||||
message={createMessage("assistant", streamingContent)}
|
isStreaming={true}
|
||||||
isStreaming={true}
|
bubbleSize={prefs.bubbleSize ?? "md"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
{/* 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">
|
<form onSubmit={handleSubmit} className="p-4">
|
||||||
{/* Active MCP Badges */}
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
<ActiveMCPBadges
|
{/* Active MCP Badges */}
|
||||||
activeMcps={activeMcps}
|
<ActiveMCPBadges
|
||||||
mcpNames={mcpNames}
|
|
||||||
onRemove={handleRemoveMCP}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{/* MCP Selector */}
|
|
||||||
<MCPSelector
|
|
||||||
activeMcps={activeMcps}
|
activeMcps={activeMcps}
|
||||||
onToggleMCP={handleToggleMCP}
|
mcpNames={mcpNames}
|
||||||
onClearAll={handleClearAllMCPs}
|
onRemove={handleRemoveMCP}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<textarea
|
<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">
|
||||||
value={inputContent}
|
<div className="flex gap-2 p-2">
|
||||||
onChange={(e) => setInputContent(e.target.value)}
|
{/* MCP Selector */}
|
||||||
onKeyDown={handleKeyDown}
|
<MCPSelector
|
||||||
placeholder={
|
activeMcps={activeMcps}
|
||||||
selectedModel ? "Type a message..." : "Select a model first..."
|
onToggleMCP={handleToggleMCP}
|
||||||
}
|
onClearAll={handleClearAllMCPs}
|
||||||
disabled={!selectedModel || isStreaming}
|
onOpenInstallModal={() => setShowManualInstall(true)}
|
||||||
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}
|
<textarea
|
||||||
/>
|
ref={inputRef}
|
||||||
<button
|
value={inputContent}
|
||||||
type={isStreaming ? "button" : "submit"}
|
onChange={(e) => {
|
||||||
onClick={isStreaming ? handleStop : undefined}
|
const val = e.target.value;
|
||||||
disabled={
|
setInputContent(val);
|
||||||
isStreaming ? false : !inputContent.trim() || !selectedModel
|
if (val.startsWith("/")) {
|
||||||
}
|
setShowCommandMenu(true);
|
||||||
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"
|
const q = val.slice(1).trim();
|
||||||
>
|
setCommandQuery(q);
|
||||||
{isStreaming ? (
|
setHighlightedCmd(0);
|
||||||
<Square className="h-4 w-4" />
|
} else {
|
||||||
) : (
|
setShowCommandMenu(false);
|
||||||
<Send className="h-4 w-4" />
|
}
|
||||||
|
}}
|
||||||
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -547,6 +795,14 @@ export function ChatContainer({
|
|||||||
modelName={modelToPull}
|
modelName={modelToPull}
|
||||||
onConfirmPull={handlePullModel}
|
onConfirmPull={handlePullModel}
|
||||||
/>
|
/>
|
||||||
|
<MCPManualInstallModal
|
||||||
|
isOpen={showManualInstall}
|
||||||
|
onClose={() => setShowManualInstall(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
// Fechar e opcionalmente poderíamos sinalizar o MCPSelector a recarregar.
|
||||||
|
setShowManualInstall(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,21 @@ import { formatTimestamp } from "@/lib/chat";
|
|||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
bubbleSize?: "sm" | "md" | "lg";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessage({
|
export function ChatMessage({
|
||||||
message,
|
message,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
|
bubbleSize = "md",
|
||||||
}: ChatMessageProps) {
|
}: ChatMessageProps) {
|
||||||
const isUser = message.role === "user";
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -28,7 +36,7 @@ export function ChatMessage({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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
|
isUser
|
||||||
? "bg-[var(--accent)] text-white"
|
? "bg-[var(--accent)] text-white"
|
||||||
: "bg-[var(--surface)] text-[var(--foreground)]"
|
: "bg-[var(--surface)] text-[var(--foreground)]"
|
||||||
@@ -43,7 +51,7 @@ export function ChatMessage({
|
|||||||
</div>
|
</div>
|
||||||
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
||||||
{!isUser && !isStreaming && (
|
{!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} />
|
<MessageActions content={message.content} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -122,7 +130,7 @@ export function ChatInput({
|
|||||||
disabled={disabled}
|
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"
|
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}
|
rows={1}
|
||||||
maxLength={2000}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type={isStreaming ? "button" : "submit"}
|
type={isStreaming ? "button" : "submit"}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export function ConversationSidebar({
|
|||||||
}: ConversationSidebarProps) {
|
}: ConversationSidebarProps) {
|
||||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConversations();
|
loadConversations();
|
||||||
@@ -66,21 +68,74 @@ export function ConversationSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredConversations = conversations.filter((c) =>
|
||||||
|
c.title.toLowerCase().includes(query.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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
|
<button
|
||||||
onClick={onNewConversation}
|
onClick={async () => {
|
||||||
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"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
Nova Conversa
|
{creating ? "Criando..." : "Nova Conversa"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Lista de Conversas */}
|
{/* Lista de Conversas */}
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
||||||
Carregando...
|
Carregando...
|
||||||
@@ -89,15 +144,19 @@ export function ConversationSidebar({
|
|||||||
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
||||||
Nenhuma conversa ainda
|
Nenhuma conversa ainda
|
||||||
</div>
|
</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
|
<div
|
||||||
key={conv.id}
|
key={conv.id}
|
||||||
onClick={() => onSelectConversation(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 ${
|
className={`w-full flex items-center gap-2 p-3 rounded-lg mb-1 text-left transition-colors group cursor-pointer ${
|
||||||
currentConversationId === conv.id
|
currentConversationId === conv.id
|
||||||
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
? "bg-[var(--accent)]/12 text-[var(--accent)] ring-1 ring-[var(--accent)]/30"
|
||||||
: "hover:bg-[var(--background)]"
|
: "hover:bg-[color-mix(in_oklab,var(--surface),black_6%)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4 flex-shrink-0" />
|
<MessageSquare className="h-4 w-4 flex-shrink-0" />
|
||||||
|
|||||||
@@ -14,16 +14,19 @@ interface MCPSelectorProps {
|
|||||||
activeMcps: string[];
|
activeMcps: string[];
|
||||||
onToggleMCP: (mcpId: string) => void;
|
onToggleMCP: (mcpId: string) => void;
|
||||||
onClearAll: () => void;
|
onClearAll: () => void;
|
||||||
|
onOpenInstallModal?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MCPSelector({
|
export function MCPSelector({
|
||||||
activeMcps,
|
activeMcps,
|
||||||
onToggleMCP,
|
onToggleMCP,
|
||||||
onClearAll,
|
onClearAll,
|
||||||
|
onOpenInstallModal,
|
||||||
}: MCPSelectorProps) {
|
}: MCPSelectorProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [availableMcps, setAvailableMcps] = useState<MCPForChat[]>([]);
|
const [availableMcps, setAvailableMcps] = useState<MCPForChat[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Carregar MCPs disponíveis
|
// Carregar MCPs disponíveis
|
||||||
@@ -79,6 +82,19 @@ export function MCPSelector({
|
|||||||
onToggleMCP(mcpId);
|
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 (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
{/* Button */}
|
{/* Button */}
|
||||||
@@ -89,7 +105,7 @@ export function MCPSelector({
|
|||||||
>
|
>
|
||||||
<Plug className="h-4 w-4" />
|
<Plug className="h-4 w-4" />
|
||||||
{activeMcps.length > 0 && (
|
{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}
|
{activeMcps.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -102,36 +118,60 @@ export function MCPSelector({
|
|||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
{isOpen && (
|
{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 */}
|
{/* Header */}
|
||||||
<div className="p-3 border-b border-[var(--border)] flex items-center justify-between">
|
<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 gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<Plug className="h-4 w-4" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-sm">MCP Tools</span>
|
<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>
|
||||||
{activeMcps.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={onClearAll}
|
|
||||||
className="text-xs text-red-500 hover:text-red-600 font-medium"
|
|
||||||
>
|
|
||||||
Clear All
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="overflow-y-auto flex-1">
|
<div className="overflow-y-auto flex-1">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-4 text-center text-sm text-[var(--muted-foreground)]">
|
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
|
||||||
Loading MCPs...
|
Carregando MCPs...
|
||||||
</div>
|
</div>
|
||||||
) : availableMcps.length === 0 ? (
|
) : availableMcps.length === 0 ? (
|
||||||
<div className="p-4 text-center text-sm text-[var(--muted-foreground)]">
|
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
|
||||||
No MCPs installed
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{availableMcps.map((mcp) => {
|
{filtered.map((mcp) => {
|
||||||
const isActive = activeMcps.includes(mcp.mcpId);
|
const isActive = activeMcps.includes(mcp.mcpId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -139,7 +179,7 @@ export function MCPSelector({
|
|||||||
key={mcp.mcpId}
|
key={mcp.mcpId}
|
||||||
className={`p-3 rounded-md mb-2 cursor-pointer transition-colors ${
|
className={`p-3 rounded-md mb-2 cursor-pointer transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? "bg-[var(--primary)]/10 border border-[var(--primary)]/30"
|
? "bg-[var(--accent)]/10 border border-[var(--accent)]/30"
|
||||||
: "hover:bg-[var(--surface)]"
|
: "hover:bg-[var(--surface)]"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleToggle(mcp.mcpId)}
|
onClick={() => handleToggle(mcp.mcpId)}
|
||||||
@@ -157,9 +197,9 @@ export function MCPSelector({
|
|||||||
{mcp.name}
|
{mcp.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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" />
|
<Zap className="h-3 w-3" />
|
||||||
<span>{mcp.toolCount} tools available</span>
|
<span>{mcp.toolCount} ferramentas disponíveis</span>
|
||||||
</div>
|
</div>
|
||||||
{isActive && mcp.tools.length > 0 && (
|
{isActive && mcp.tools.length > 0 && (
|
||||||
<div className="mt-2 ml-6">
|
<div className="mt-2 ml-6">
|
||||||
@@ -167,7 +207,7 @@ export function MCPSelector({
|
|||||||
{mcp.tools.slice(0, 3).map((tool, idx) => (
|
{mcp.tools.slice(0, 3).map((tool, idx) => (
|
||||||
<span
|
<span
|
||||||
key={idx}
|
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}
|
title={tool.description}
|
||||||
>
|
>
|
||||||
{tool.name}
|
{tool.name}
|
||||||
@@ -191,14 +231,21 @@ export function MCPSelector({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer info */}
|
{/* Footer info */}
|
||||||
{activeMcps.length > 0 && (
|
<div className="p-3 border-t border-[var(--border)] bg-[var(--surface)]/50 flex items-center justify-between">
|
||||||
<div className="p-3 border-t border-[var(--border)] bg-[var(--surface)]/50">
|
<p className="text-xs text-[var(--foreground)]/60">
|
||||||
<p className="text-xs text-[var(--muted-foreground)]">
|
{activeMcps.length > 0
|
||||||
AI can use {activeMcps.length} MCP
|
? `${activeMcps.length} MCPs ativos para enriquecer as respostas`
|
||||||
{activeMcps.length > 1 ? "s" : ""} to help answer your questions
|
: "Nenhum MCP ativo"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ type SettingsModalProps = {
|
|||||||
onSystemPromptChange: (prompt: string) => void;
|
onSystemPromptChange: (prompt: string) => void;
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
models: Array<{ id: string; name: string; device: string }>;
|
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({
|
export function SettingsModal({
|
||||||
@@ -15,14 +20,20 @@ export function SettingsModal({
|
|||||||
onSystemPromptChange,
|
onSystemPromptChange,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
models,
|
models,
|
||||||
|
design,
|
||||||
}: SettingsModalProps) {
|
}: SettingsModalProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [tempPrompt, setTempPrompt] = useState(systemPrompt);
|
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 selectedModelData = models.find((m) => m.id === selectedModel);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSystemPromptChange(tempPrompt);
|
onSystemPromptChange(tempPrompt);
|
||||||
|
if (design?.onChange) {
|
||||||
|
design.onChange({ chatLayout: tempChatLayout, bubbleSize: tempBubbleSize });
|
||||||
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,6 +107,38 @@ export function SettingsModal({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -129,18 +129,18 @@ export function MCPManualInstallModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
<div className="bg-[var(--background)] rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto border border-[var(--border)]">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between">
|
<div className="sticky top-0 border-b border-[var(--border)] p-4 flex items-center justify-between 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 gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
<Upload className="h-5 w-5 text-[var(--accent)]" />
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
<h2 className="text-xl font-semibold">
|
||||||
Install MCP Manually
|
Instalar MCP Manualmente
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
className="p-2 hover:bg-[var(--surface)] rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -149,46 +149,45 @@ export function MCPManualInstallModal({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
<div className="rounded-lg p-4 border border-[var(--border)] bg-[var(--surface)]/60">
|
||||||
<h3 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
<h3 className="font-medium mb-2">
|
||||||
How to use
|
Como usar
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-blue-700 dark:text-blue-300 mb-3">
|
<p className="text-sm mb-3 text-[var(--foreground)]/80">
|
||||||
Cole a configuração do servidor MCP no formato do Claude Desktop
|
Cole a configuração do servidor MCP no formato do Claude Desktop (claude_desktop_config.json).
|
||||||
(claude_desktop_config.json). Apenas instalação manual é suportada
|
Apenas instalação manual é suportada neste projeto.
|
||||||
neste projeto.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handlePasteExample}
|
onClick={handlePasteExample}
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
className="text-sm text-[var(--accent)] hover:underline"
|
||||||
>
|
>
|
||||||
Click here to load an example
|
Clique aqui para carregar um exemplo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MCP ID (optional) */}
|
{/* MCP ID (optional) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
MCP ID (optional)
|
MCP ID (opcional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={mcpId}
|
value={mcpId}
|
||||||
onChange={(e) => setMcpId(e.target.value)}
|
onChange={(e) => setMcpId(e.target.value)}
|
||||||
placeholder="my-custom-mcp (auto-detected if empty)"
|
placeholder="my-custom-mcp (detectado automaticamente se vazio)"
|
||||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full px-4 py-2 border border-[var(--border)] rounded-lg bg-[var(--background)] focus:ring-2 focus:ring-[var(--accent)] focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Configuration JSON */}
|
{/* Configuration JSON */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Configuration JSON *
|
JSON de Configuração *
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={configJson}
|
value={configJson}
|
||||||
onChange={(e) => handleConfigChange(e.target.value)}
|
onChange={(e) => handleConfigChange(e.target.value)}
|
||||||
placeholder={`Paste your configuration here, e.g.:
|
placeholder={`Cole a configuração aqui, ex.:
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"time-server": {
|
"time-server": {
|
||||||
@@ -198,7 +197,7 @@ export function MCPManualInstallModal({
|
|||||||
}
|
}
|
||||||
}`}
|
}`}
|
||||||
rows={15}
|
rows={15}
|
||||||
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
className="w-full px-4 py-3 border border-[var(--border)] rounded-lg bg-[var(--background)] font-mono text-sm focus:ring-2 focus:ring-[var(--accent)] focus:border-transparent resize-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Validation Status */}
|
{/* Validation Status */}
|
||||||
@@ -207,16 +206,12 @@ export function MCPManualInstallModal({
|
|||||||
{validationError ? (
|
{validationError ? (
|
||||||
<>
|
<>
|
||||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
<span className="text-sm text-red-600">{validationError}</span>
|
||||||
{validationError}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : parsedConfig ? (
|
) : parsedConfig ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
<span className="text-sm text-green-600 dark:text-green-400">
|
<span className="text-sm text-green-600">Configuração válida</span>
|
||||||
Valid configuration
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,24 +220,22 @@ export function MCPManualInstallModal({
|
|||||||
|
|
||||||
{/* Preview */}
|
{/* Preview */}
|
||||||
{parsedConfig && !validationError && (
|
{parsedConfig && !validationError && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<div className="border border-[var(--border)] rounded-lg p-4 bg-[var(--surface)]/50">
|
||||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
|
<h3 className="font-medium mb-3">Pré-visualização</h3>
|
||||||
Preview
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
{parsedConfig.mcpServers ? (
|
{parsedConfig.mcpServers ? (
|
||||||
Object.entries(parsedConfig.mcpServers).map(
|
Object.entries(parsedConfig.mcpServers).map(
|
||||||
([name, config]: [string, any]) => (
|
([name, config]: [string, any]) => (
|
||||||
<div key={name} className="space-y-1">
|
<div key={name} className="space-y-1">
|
||||||
<div className="text-gray-700 dark:text-gray-300">
|
<div className="">
|
||||||
<span className="font-medium">Server:</span> {name}
|
<span className="font-medium">Server:</span> {name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-600 dark:text-gray-400">
|
<div className="text-[var(--foreground)]/80">
|
||||||
<span className="font-medium">Command:</span>{" "}
|
<span className="font-medium">Command:</span>{" "}
|
||||||
{config.command} {(config.args || []).join(" ")}
|
{config.command} {(config.args || []).join(" ")}
|
||||||
</div>
|
</div>
|
||||||
{config.env && (
|
{config.env && (
|
||||||
<div className="text-gray-600 dark:text-gray-400">
|
<div className="text-[var(--foreground)]/80">
|
||||||
<span className="font-medium">Environment:</span>{" "}
|
<span className="font-medium">Environment:</span>{" "}
|
||||||
{Object.keys(config.env).length} variables
|
{Object.keys(config.env).length} variables
|
||||||
</div>
|
</div>
|
||||||
@@ -252,13 +245,13 @@ export function MCPManualInstallModal({
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-gray-600 dark:text-gray-400">
|
<div className="text-[var(--foreground)]/80">
|
||||||
<span className="font-medium">Command:</span>{" "}
|
<span className="font-medium">Command:</span>{" "}
|
||||||
{parsedConfig.command}{" "}
|
{parsedConfig.command}{" "}
|
||||||
{(parsedConfig.args || []).join(" ")}
|
{(parsedConfig.args || []).join(" ")}
|
||||||
</div>
|
</div>
|
||||||
{parsedConfig.env && (
|
{parsedConfig.env && (
|
||||||
<div className="text-gray-600 dark:text-gray-400">
|
<div className="text-[var(--foreground)]/80">
|
||||||
<span className="font-medium">Environment:</span>{" "}
|
<span className="font-medium">Environment:</span>{" "}
|
||||||
{Object.keys(parsedConfig.env).length} variables
|
{Object.keys(parsedConfig.env).length} variables
|
||||||
</div>
|
</div>
|
||||||
@@ -275,12 +268,8 @@ export function MCPManualInstallModal({
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertCircle className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
|
<AlertCircle className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="font-medium text-red-900 dark:text-red-100">
|
<h4 className="font-medium text-red-900">Erro na instalação</h4>
|
||||||
Installation Error
|
<p className="text-sm text-red-700 mt-1">{error}</p>
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,28 +277,28 @@ export function MCPManualInstallModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="sticky bottom-0 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 p-4 flex gap-3 justify-end">
|
<div className="sticky bottom-0 bg-[var(--surface)]/60 border-t border-[var(--border)] p-4 flex gap-3 justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={isInstalling}
|
disabled={isInstalling}
|
||||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
className="px-4 py-2 hover:bg-[var(--surface)] rounded-lg transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleInstall}
|
onClick={handleInstall}
|
||||||
disabled={isInstalling || !parsedConfig || !!validationError}
|
disabled={isInstalling || !parsedConfig || !!validationError}
|
||||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
className="px-6 py-2 bg-[var(--accent)] text-[var(--accent-foreground)] rounded-lg hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{isInstalling ? (
|
{isInstalling ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Installing...
|
Instalando...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
Install MCP
|
Instalar MCP
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
|||||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||||
components={{
|
components={{
|
||||||
code({ inline, className, children, ...props }: any) {
|
code({ inline, className, children, ...props }: any) {
|
||||||
const text = String(children || "");
|
// Garantir extração correta do texto do bloco, mesmo com quebras/indentação
|
||||||
|
const node: any = (props as any).node;
|
||||||
|
const rawChild = node?.children?.[0]?.value ?? children;
|
||||||
|
const text = String(rawChild ?? "");
|
||||||
|
|
||||||
if (inline) {
|
if (inline) {
|
||||||
return (
|
return (
|
||||||
@@ -34,19 +37,20 @@ export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleaned = text.replace(/^\n+|\n+$/g, "");
|
||||||
return (
|
return (
|
||||||
<div className="relative my-4 rounded-lg border border-[var(--border)] bg-[var(--background)]">
|
<div className="relative my-4 rounded-lg border border-[var(--border)] bg-[var(--background)]">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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)}
|
onClick={() => navigator.clipboard.writeText(cleaned)}
|
||||||
aria-label="Copiar código"
|
aria-label="Copiar código"
|
||||||
>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<pre className="overflow-auto p-3">
|
<pre className="overflow-auto p-3">
|
||||||
<code className={className} {...props}>
|
<code className={`${className || ""} font-mono text-[var(--foreground)] whitespace-pre`} {...props}>
|
||||||
{children}
|
{cleaned}
|
||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ export function MessageActions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 opacity-70">
|
<div className="flex items-center gap-1 opacity-70 pointer-events-auto">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||||
onClick={handleCopyDefault}
|
onClick={handleCopyDefault}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -54,6 +55,7 @@ export function MessageActions({
|
|||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="rounded px-1 py-1 text-xs hover:bg-[var(--surface)]"
|
className="rounded px-1 py-1 text-xs hover:bg-[var(--surface)]"
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -65,12 +67,14 @@ export function MessageActions({
|
|||||||
{dropdownOpen && (
|
{dropdownOpen && (
|
||||||
<div className="absolute right-0 z-10 mt-1 rounded border border-[var(--border)] bg-[var(--background)] shadow-lg">
|
<div className="absolute right-0 z-10 mt-1 rounded border border-[var(--border)] bg-[var(--background)] shadow-lg">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
|
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
|
||||||
onClick={() => handleModeSelect("markdown")}
|
onClick={() => handleModeSelect("markdown")}
|
||||||
>
|
>
|
||||||
Markdown
|
Markdown
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
|
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
|
||||||
onClick={() => handleModeSelect("text")}
|
onClick={() => handleModeSelect("text")}
|
||||||
>
|
>
|
||||||
@@ -82,6 +86,7 @@ export function MessageActions({
|
|||||||
|
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -92,6 +97,7 @@ export function MessageActions({
|
|||||||
)}
|
)}
|
||||||
{onRegenerate && (
|
{onRegenerate && (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||||
onClick={onRegenerate}
|
onClick={onRegenerate}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|||||||
@@ -247,6 +247,16 @@ export function ModelDropdown({
|
|||||||
<option value="CPU">CPU Only</option>
|
<option value="CPU">CPU Only</option>
|
||||||
<option value="GPU">GPU Only</option>
|
<option value="GPU">GPU Only</option>
|
||||||
</select>
|
</select>
|
||||||
|
{/* Instalar por referência */}
|
||||||
|
{onPullModel && (
|
||||||
|
<button
|
||||||
|
onClick={() => handlePullModel("")}
|
||||||
|
className="rounded-md bg-[var(--accent)] text-white px-2 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
|
||||||
|
title="Instalar por referência (ex.: owner/model:tag)"
|
||||||
|
>
|
||||||
|
Install by ref
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,22 +27,113 @@ export function ModelPullDialog({
|
|||||||
const [isPulling, setIsPulling] = useState(false);
|
const [isPulling, setIsPulling] = useState(false);
|
||||||
const [progress, setProgress] = useState<PullProgress | null>(null);
|
const [progress, setProgress] = useState<PullProgress | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSuccess, setIsSuccess] = useState<boolean>(false);
|
||||||
|
const [editableName, setEditableName] = useState<string>(modelName);
|
||||||
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
|
const [isLoadingTags, setIsLoadingTags] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setIsPulling(false);
|
setIsPulling(false);
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setIsSuccess(false);
|
||||||
|
setEditableName(modelName);
|
||||||
|
setTags([]);
|
||||||
|
setIsLoadingTags(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
|
||||||
|
// Ao abrir, tentar buscar tags conhecidas
|
||||||
|
const base = String(modelName).split(":")[0];
|
||||||
|
setEditableName(modelName);
|
||||||
|
if (!base) {
|
||||||
|
setTags([]);
|
||||||
|
setIsLoadingTags(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoadingTags(true);
|
||||||
|
fetch(`/api/ollama/tags?name=${encodeURIComponent(base)}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data?.success && Array.isArray(data.tags)) {
|
||||||
|
setTags(data.tags);
|
||||||
|
} else {
|
||||||
|
setTags([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setTags([]))
|
||||||
|
.finally(() => setIsLoadingTags(false));
|
||||||
|
}, [isOpen, modelName]);
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
setIsPulling(true);
|
setIsPulling(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setProgress(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onConfirmPull(modelName);
|
const response = await fetch("/api/ollama/pull", {
|
||||||
onClose();
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ model: editableName.trim() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Falha ao iniciar download do modelo");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error("Resposta inválida do servidor");
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Processar diferentes estados de progresso
|
||||||
|
if (data.status === "downloading" || data.status === "pulling") {
|
||||||
|
setProgress({
|
||||||
|
status: data.status,
|
||||||
|
total: data.total,
|
||||||
|
completed: data.completed,
|
||||||
|
digest: data.digest,
|
||||||
|
});
|
||||||
|
} else if (data.status === "success") {
|
||||||
|
setProgress((prev) => ({
|
||||||
|
status: "success",
|
||||||
|
total: prev?.total,
|
||||||
|
completed: prev?.total
|
||||||
|
}));
|
||||||
|
setIsSuccess(true);
|
||||||
|
} else if (data.error) {
|
||||||
|
setError(data.detail || data.error);
|
||||||
|
} else if (data.total || data.completed) {
|
||||||
|
// Caso o status não seja "downloading" mas tenha dados de progresso
|
||||||
|
setProgress({
|
||||||
|
status: data.status || "downloading",
|
||||||
|
total: data.total,
|
||||||
|
completed: data.completed,
|
||||||
|
digest: data.digest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignorar linhas inválidas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Não fechar automaticamente; permitir usuário fechar ao concluir
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to pull model");
|
setError(err instanceof Error ? err.message : "Failed to pull model");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -50,6 +141,18 @@ export function ModelPullDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatBytes = (n?: number) => {
|
||||||
|
if (!n || n <= 0) return "0 MB";
|
||||||
|
const mb = n / 1024 / 1024;
|
||||||
|
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||||
|
const gb = mb / 1024;
|
||||||
|
return `${gb.toFixed(2)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const percent = progress?.total && progress?.completed
|
||||||
|
? Math.max(0, Math.min(100, Math.round((progress.completed / progress.total) * 100)))
|
||||||
|
: 0;
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,42 +170,90 @@ export function ModelPullDialog({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4 space-y-2">
|
||||||
<p className="text-sm text-[var(--foreground)]/80 mb-2">
|
<div>
|
||||||
Install <span className="font-medium">{modelName}</span>?
|
<p className="text-sm text-[var(--foreground)]/80 mb-2">
|
||||||
</p>
|
Confirme o identificador do modelo (nome[:tag]):
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editableName}
|
||||||
|
onChange={(e) => setEditableName(e.target.value)}
|
||||||
|
disabled={isPulling}
|
||||||
|
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||||
|
placeholder="ex.: llama3:8b-instruct"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-xs text-[var(--foreground)]/60">Tags disponíveis</p>
|
||||||
|
{isLoadingTags && (
|
||||||
|
<span className="text-xs text-[var(--foreground)]/50">Carregando…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm"
|
||||||
|
onChange={(e) => {
|
||||||
|
const base = String(modelName).split(":")[0];
|
||||||
|
const tag = e.target.value;
|
||||||
|
setEditableName(`${base}:${tag}`);
|
||||||
|
}}
|
||||||
|
defaultValue=""
|
||||||
|
disabled={isPulling}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Selecione uma tag (opcional)
|
||||||
|
</option>
|
||||||
|
{tags.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--foreground)]/50">
|
||||||
|
Nenhuma tag detectada automaticamente. Você pode digitar manualmente.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-[var(--foreground)]/60">
|
<p className="text-xs text-[var(--foreground)]/60">
|
||||||
This will download the model to your local Ollama installation.
|
Isso fará o download do modelo na sua instalação local do Ollama.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progress && (
|
{progress && (
|
||||||
<div className="mb-4 rounded-lg bg-[var(--surface)] p-3">
|
<div className="mb-4 rounded-lg bg-[var(--surface)] p-3">
|
||||||
<div className="flex items-center justify-between text-sm mb-2">
|
<div className="flex items-center justify-between text-sm mb-2">
|
||||||
<span>Downloading...</span>
|
<span className="flex items-center gap-2">
|
||||||
<span>
|
{!isSuccess && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{progress.total && progress.completed
|
{isSuccess ? "Download concluído" : "Baixando modelo..."}
|
||||||
? `${Math.round(
|
|
||||||
(progress.completed / progress.total) * 100,
|
|
||||||
)}%`
|
|
||||||
: "0%"}
|
|
||||||
</span>
|
</span>
|
||||||
|
{progress.total && progress.completed && <span>{percent}%</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-[var(--border)] rounded-full h-2">
|
<div className="w-full bg-[var(--border)] rounded-full h-2 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="bg-[var(--accent)] h-2 rounded-full transition-all duration-300"
|
className={`h-2 transition-all duration-300 ${
|
||||||
style={{
|
isSuccess ? "bg-green-500" : "bg-[var(--accent)]"
|
||||||
width:
|
}`}
|
||||||
progress.total && progress.completed
|
style={{ width: `${percent}%` }}
|
||||||
? `${(progress.completed / progress.total) * 100}%`
|
|
||||||
: "0%",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{progress.digest && (
|
{progress.total && progress.completed ? (
|
||||||
<p className="text-xs text-[var(--foreground)]/60 mt-1">
|
<div className="mt-2 flex items-center justify-between text-xs text-[var(--foreground)]/60">
|
||||||
{progress.digest.slice(0, 12)}...
|
<span>
|
||||||
</p>
|
{formatBytes(progress.completed)} / {formatBytes(progress.total)}
|
||||||
|
</span>
|
||||||
|
{progress?.digest && (
|
||||||
|
<span>sha256: {progress.digest.slice(0, 12)}...</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 text-xs text-[var(--foreground)]/60">
|
||||||
|
Aguardando informações de progresso...
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -119,25 +270,27 @@ export function ModelPullDialog({
|
|||||||
disabled={isPulling}
|
disabled={isPulling}
|
||||||
className="px-4 py-2 text-sm rounded-md border border-[var(--border)] transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
className="px-4 py-2 text-sm rounded-md border border-[var(--border)] transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Cancel
|
{isSuccess ? "Fechar" : "Cancelar"}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={isPulling}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isPulling ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Installing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
Install
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
{!isSuccess && (
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isPulling}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPulling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Installing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Install
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ export function SettingsModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { prefs, ready, update } = useUserPrefs();
|
const { prefs, ready, update } = useUserPrefs();
|
||||||
const [tab, setTab] = useState<"general" | "execution">("general");
|
const [tab, setTab] = useState<"general" | "execution" | "device">("general");
|
||||||
const [localPrompt, setLocalPrompt] = useState("");
|
const [localPrompt, setLocalPrompt] = useState("");
|
||||||
const [device, setDevice] = useState<DevicePref>("auto");
|
const [device, setDevice] = useState<DevicePref>("auto");
|
||||||
const [gpuIndex, setGpuIndex] = useState(0);
|
const [gpuIndex, setGpuIndex] = useState(0);
|
||||||
const [numGpu, setNumGpu] = useState(1);
|
const [numGpu, setNumGpu] = useState(1);
|
||||||
|
const [chatLayout, setChatLayout] = useState<"compact" | "edge">("compact");
|
||||||
|
const [bubbleSize, setBubbleSize] = useState<"sm" | "md" | "lg">("md");
|
||||||
|
const [sysInfo, setSysInfo] = useState<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
@@ -37,10 +40,12 @@ export function SettingsModal({
|
|||||||
setDevice(prefs.device);
|
setDevice(prefs.device);
|
||||||
setGpuIndex(prefs.gpuIndex);
|
setGpuIndex(prefs.gpuIndex);
|
||||||
setNumGpu(prefs.numGpu);
|
setNumGpu(prefs.numGpu);
|
||||||
|
setChatLayout((prefs as any).chatLayout ?? "compact");
|
||||||
|
setBubbleSize((prefs as any).bubbleSize ?? "md");
|
||||||
}, [ready, prefs]);
|
}, [ready, prefs]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
update({ systemPrompt: localPrompt, device, gpuIndex, numGpu });
|
update({ systemPrompt: localPrompt, device, gpuIndex, numGpu, chatLayout, bubbleSize });
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,6 +87,16 @@ export function SettingsModal({
|
|||||||
>
|
>
|
||||||
Execution
|
Execution
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-3 py-2 text-sm ${
|
||||||
|
tab === "device"
|
||||||
|
? "border-b-2 border-accent font-medium"
|
||||||
|
: "text-[var(--foreground)]/60"
|
||||||
|
}`}
|
||||||
|
onClick={() => setTab("device")}
|
||||||
|
>
|
||||||
|
Device
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "general" && (
|
{tab === "general" && (
|
||||||
@@ -96,6 +111,32 @@ export function SettingsModal({
|
|||||||
<p className="text-xs text-[var(--foreground)]/60">
|
<p className="text-xs text-[var(--foreground)]/60">
|
||||||
Sent at the start of a conversation to guide model behavior.
|
Sent at the start of a conversation to guide model behavior.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Chat layout</label>
|
||||||
|
<select
|
||||||
|
value={chatLayout}
|
||||||
|
onChange={(e) => setChatLayout(e.target.value as any)}
|
||||||
|
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
>
|
||||||
|
<option value="compact">Compact (centered)</option>
|
||||||
|
<option value="edge">Edge-to-edge</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Bubble size</label>
|
||||||
|
<select
|
||||||
|
value={bubbleSize}
|
||||||
|
onChange={(e) => setBubbleSize(e.target.value as any)}
|
||||||
|
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
>
|
||||||
|
<option value="sm">Small</option>
|
||||||
|
<option value="md">Medium</option>
|
||||||
|
<option value="lg">Large</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -157,6 +198,17 @@ export function SettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "device" && (
|
||||||
|
<DeviceStatus
|
||||||
|
sysInfo={sysInfo}
|
||||||
|
setSysInfo={setSysInfo}
|
||||||
|
device={device}
|
||||||
|
gpuIndex={gpuIndex}
|
||||||
|
setDevice={setDevice}
|
||||||
|
setGpuIndex={setGpuIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -175,3 +227,110 @@ export function SettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DeviceStatus({ sysInfo, setSysInfo, device, gpuIndex, setDevice, setGpuIndex }: { sysInfo: any; setSysInfo: (v: any) => void; device: DevicePref; gpuIndex: number; setDevice: (d: DevicePref) => void; setGpuIndex: (i: number) => void; }) {
|
||||||
|
// polling a cada 2s
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const load = () => {
|
||||||
|
fetch("/api/system/status")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => mounted && d.success && setSysInfo(d.info))
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 2000);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [setSysInfo]);
|
||||||
|
|
||||||
|
// Defaultar GPU 0 quando houver GPU e usuário escolher GPU
|
||||||
|
useEffect(() => {
|
||||||
|
const gpus = Array.isArray(sysInfo?.gpus) ? sysInfo.gpus : [];
|
||||||
|
if (device === "gpu" && gpus.length > 0) {
|
||||||
|
if (typeof gpuIndex !== "number" || gpuIndex < 0 || gpuIndex >= gpus.length) {
|
||||||
|
setGpuIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [device, gpuIndex, sysInfo, setGpuIndex]);
|
||||||
|
|
||||||
|
if (!sysInfo) {
|
||||||
|
return <div className="text-sm text-[var(--foreground)]/60">Loading device info...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memPct = sysInfo.memory.total > 0 ? (sysInfo.memory.used / sysInfo.memory.total) * 100 : 0;
|
||||||
|
const memModules = Array.isArray(sysInfo.memory.modules) ? sysInfo.memory.modules : [];
|
||||||
|
const gpus = Array.isArray(sysInfo.gpus) ? sysInfo.gpus : [];
|
||||||
|
const hasGpu = gpus.length > 0;
|
||||||
|
const currentGpu = hasGpu ? Math.min(Math.max(0, gpuIndex || 0), gpus.length - 1) : 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="rounded-md border border-border p-3">
|
||||||
|
<div className="font-medium mb-1">Sistema</div>
|
||||||
|
<div>OS: {sysInfo.os?.platform || sysInfo.platform} {sysInfo.os?.release || sysInfo.release}</div>
|
||||||
|
<div>Arch: {sysInfo.os?.arch || sysInfo.arch}</div>
|
||||||
|
<div>CPU: {sysInfo.cpu?.model} ({sysInfo.cpu?.cores} cores)</div>
|
||||||
|
<div>Uptime: {Math.round(sysInfo.uptimeSec / 60)} min</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border p-3">
|
||||||
|
<div className="font-medium mb-1">Carga</div>
|
||||||
|
<div>Load (1m/5m/15m): {Number(sysInfo.metrics?.loadAvg?.["1m"] ?? 0).toFixed(2)} / {Number(sysInfo.metrics?.loadAvg?.["5m"] ?? 0).toFixed(2)} / {Number(sysInfo.metrics?.loadAvg?.["15m"] ?? 0).toFixed(2)}</div>
|
||||||
|
<div>Memória: {(sysInfo.memory.used/1024/1024).toFixed(0)}MB / {(sysInfo.memory.total/1024/1024).toFixed(0)}MB ({memPct.toFixed(0)}%)</div>
|
||||||
|
<div>Módulos de RAM: {memModules.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-3 text-sm">
|
||||||
|
<div className="font-medium mb-1">Aplicativo</div>
|
||||||
|
<div>RSS: {(sysInfo.metrics?.process?.rss/1024/1024).toFixed(0)}MB | Heap: {(sysInfo.metrics?.process?.heapUsed/1024/1024).toFixed(0)}MB / {(sysInfo.metrics?.process?.heapTotal/1024/1024).toFixed(0)}MB</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-3 text-sm">
|
||||||
|
<div className="font-medium mb-2">Placas de vídeo</div>
|
||||||
|
{hasGpu ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{gpus.map((g: any) => (
|
||||||
|
<div key={g.index} className="flex items-center justify-between rounded-md border border-border p-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{g.name}</div>
|
||||||
|
<div className="text-[var(--foreground)]/70">{g.vendor || ""} {g.vramMB ? `• ${g.vramMB}MB VRAM` : ""} {g.bus ? `• ${g.bus}` : ""}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">index: {g.index}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[var(--foreground)]/60">Nenhuma GPU detectada</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-border p-3 text-sm">
|
||||||
|
<div className="font-medium mb-2">Dispositivo principal</div>
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||||
|
<label className="inline-flex items-center gap-2">
|
||||||
|
<input type="radio" name="dev" checked={device === "cpu"} onChange={() => setDevice("cpu")}/> CPU
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-2">
|
||||||
|
<input type="radio" name="dev" checked={device === "gpu"} onChange={() => setDevice("gpu")} disabled={!hasGpu}/> GPU
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
disabled={device !== "gpu" || !hasGpu}
|
||||||
|
value={currentGpu}
|
||||||
|
onChange={(e) => setGpuIndex(Number(e.target.value))}
|
||||||
|
className="mt-1 w-full md:w-auto rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
>
|
||||||
|
{gpus.map((g: any) => (
|
||||||
|
<option key={g.index} value={g.index}>{g.index}: {g.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-[var(--foreground)]/60">Se houver GPU, ela será o padrão quando selecionar GPU.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-[var(--foreground)]/60">Para métricas detalhadas por modelo/GPU, integrações com o servidor de modelos serão adicionadas depois.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "goonsai/josiefied-qwen2.5-7b-abliterated-v2:latest",
|
||||||
|
"installed": true,
|
||||||
|
"lastUsedAt": "2025-10-29T20:40:19.434Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ type Prefs = {
|
|||||||
device: DevicePref;
|
device: DevicePref;
|
||||||
gpuIndex: number;
|
gpuIndex: number;
|
||||||
numGpu: number;
|
numGpu: number;
|
||||||
|
// Preferências de design
|
||||||
|
chatLayout?: "compact" | "edge"; // compact = centralizado com max-width; edge = até as bordas
|
||||||
|
bubbleSize?: "sm" | "md" | "lg"; // tamanhos de bolha
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPrefs: Prefs = {
|
const defaultPrefs: Prefs = {
|
||||||
@@ -18,6 +21,8 @@ const defaultPrefs: Prefs = {
|
|||||||
device: "auto",
|
device: "auto",
|
||||||
gpuIndex: 0,
|
gpuIndex: 0,
|
||||||
numGpu: 1,
|
numGpu: 1,
|
||||||
|
chatLayout: "compact",
|
||||||
|
bubbleSize: "md",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useUserPrefs() {
|
export function useUserPrefs() {
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ export async function searchRemoteModels(query?: string): Promise<any[]> {
|
|||||||
return filtered.map((m) => ({
|
return filtered.map((m) => ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
description: m.description,
|
description: m.description,
|
||||||
|
url: m.url,
|
||||||
|
tags_count: m.tags_count,
|
||||||
installed: installedNames.has(m.name),
|
installed: installedNames.has(m.name),
|
||||||
remote: true,
|
remote: true,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -127,8 +127,9 @@ export class MCPInstallerService {
|
|||||||
const logs: string[] = [];
|
const logs: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const npmBin = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
const result = await spawnWithProgress(
|
const result = await spawnWithProgress(
|
||||||
"npm",
|
npmBin,
|
||||||
["install", packageName, "--prefix", npmDir],
|
["install", packageName, "--prefix", npmDir],
|
||||||
{
|
{
|
||||||
cwd: npmDir,
|
cwd: npmDir,
|
||||||
@@ -171,15 +172,34 @@ export class MCPInstallerService {
|
|||||||
|
|
||||||
// Construir ambiente
|
// Construir ambiente
|
||||||
// Para NPM, executar npx do diretório onde foi instalado (não usa --prefix que não funciona)
|
// Para NPM, executar npx do diretório onde foi instalado (não usa --prefix que não funciona)
|
||||||
|
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||||
const environment: MCPEnvironment = {
|
const environment: MCPEnvironment = {
|
||||||
type: "npm",
|
type: "npm",
|
||||||
path: npmDir,
|
path: npmDir,
|
||||||
executable: "npx",
|
executable: npxBin,
|
||||||
args: [packageName],
|
args: ["-y", packageName],
|
||||||
};
|
};
|
||||||
|
|
||||||
return environment;
|
return environment;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
const msg = String(error?.message || "");
|
||||||
|
if (msg.includes("spawn EINVAL") || msg.includes("Failed to spawn process") || msg.includes("ENOENT")) {
|
||||||
|
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||||
|
onProgress({
|
||||||
|
status: "installing" as MCPInstallationStatus,
|
||||||
|
message: "Falling back to npx runtime execution (no preinstall)",
|
||||||
|
percentage: 60,
|
||||||
|
logs: config.enableLogs ? [msg] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const environment: MCPEnvironment = {
|
||||||
|
type: "npm",
|
||||||
|
path: npmDir,
|
||||||
|
executable: npxBin,
|
||||||
|
args: ["-y", packageName],
|
||||||
|
};
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
throw new Error(`NPM installation failed: ${error.message}`);
|
throw new Error(`NPM installation failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export class MCPValidatorService {
|
|||||||
cwd: environment.path, // Executar do diretório de instalação
|
cwd: environment.path, // Executar do diretório de instalação
|
||||||
env,
|
env,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
shell: process.platform === "win32", // Workaround para *.cmd no Windows
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
@@ -250,6 +251,7 @@ export class MCPValidatorService {
|
|||||||
cwd: environment.path, // Executar do diretório de instalação
|
cwd: environment.path, // Executar do diretório de instalação
|
||||||
env,
|
env,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
shell: process.platform === "win32",
|
||||||
});
|
});
|
||||||
|
|
||||||
let hasResolved = false;
|
let hasResolved = false;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export type SavedRemoteModel = {
|
||||||
|
name: string; // full ref, e.g., owner/model:tag or model:tag
|
||||||
|
description?: string;
|
||||||
|
url?: string;
|
||||||
|
tags_count?: number;
|
||||||
|
installed?: boolean;
|
||||||
|
lastUsedAt?: string; // ISO
|
||||||
|
};
|
||||||
|
|
||||||
|
type SavedRemoteFile = {
|
||||||
|
models: SavedRemoteModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFilePath() {
|
||||||
|
return path.join(process.cwd(), "database", "user-remote-models.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFile(): Promise<void> {
|
||||||
|
const file = getFilePath();
|
||||||
|
try {
|
||||||
|
await fs.access(file);
|
||||||
|
} catch {
|
||||||
|
const initial: SavedRemoteFile = { models: [] };
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
await fs.writeFile(file, JSON.stringify(initial, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSavedRemoteModels(): Promise<SavedRemoteModel[]> {
|
||||||
|
await ensureFile();
|
||||||
|
const raw = await fs.readFile(getFilePath(), "utf-8");
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(raw) as SavedRemoteFile;
|
||||||
|
return Array.isArray(json.models) ? json.models : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addSavedRemoteModel(entry: SavedRemoteModel): Promise<void> {
|
||||||
|
await ensureFile();
|
||||||
|
const current = await getSavedRemoteModels();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const idx = current.findIndex((m) => m.name === entry.name);
|
||||||
|
const merged: SavedRemoteModel = {
|
||||||
|
...entry,
|
||||||
|
installed: entry.installed ?? false,
|
||||||
|
lastUsedAt: entry.lastUsedAt ?? nowIso,
|
||||||
|
};
|
||||||
|
if (idx >= 0) {
|
||||||
|
current[idx] = { ...current[idx], ...merged, lastUsedAt: nowIso };
|
||||||
|
} else {
|
||||||
|
current.unshift(merged);
|
||||||
|
}
|
||||||
|
const toWrite: SavedRemoteFile = { models: current.slice(0, 500) };
|
||||||
|
await fs.writeFile(getFilePath(), JSON.stringify(toWrite, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user