docs: replace README with Next.js boilerplate template

Updated README.md from a detailed OllaHub project description to the standard Next.js getting started template, likely to initialize or reset the project as a Next.js application.
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-28 12:51:19 -03:00
1 parent 58e5522700
commit a3f8226fda
42 files changed
+4465 -314

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+23 -314
View File
@@ -1,327 +1,36 @@
# OllaHub
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
Plataforma desktop multiplataforma para integração de modelos IA locais com servidores Model Context Protocol
## Getting Started
---
First, run the development server:
## Visão Geral
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
OllaHub é uma solução desktop nativa desenvolvida com Rust e Tauri que unifica o gerenciamento de Large Language Models locais através do Ollama com a extensibilidade do Model Context Protocol. A plataforma prioriza privacidade total, performance superior e experiência nativa, oferecendo uma alternativa local-first às soluções baseadas em nuvem.
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
O projeto segue uma metodologia de desenvolvimento progressivo, iniciando com um MVP web para validação de conceitos antes da implementação completa da aplicação desktop.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
### Principais Características
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
**Inferência 100% Local**
Todo processamento de IA ocorre localmente através do Ollama, garantindo privacidade absoluta dos dados sem dependência de serviços externos.
## Learn More
**Integração Nativa com MCP**
Suporte completo ao Model Context Protocol permitindo extensão de capacidades através de servidores especializados para acesso a sistemas de arquivos, APIs externas, bancos de dados e ferramentas de desenvolvimento.
To learn more about Next.js, take a look at the following resources:
**Performance Excepcional**
Backend em Rust oferece velocidade de execução até 3x superior comparado a alternativas baseadas em Node.js ou Python, com consumo de memória reduzido em até 50%.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
**Bundle Compacto**
Distribuição entre 3-5 MB comparado aos 85 MB típicos de aplicações Electron, facilitando download e instalação.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
**Experiência Desktop Nativa**
Interface responsiva construída com Tauri 2.0 e SvelteKit, priorizando Linux com suporte planejado para Windows.
## Deploy on Vercel
**Streaming em Tempo Real**
Sistema de streaming bidirecional para renderização progressiva de respostas longas, mantendo interface fluida durante processamento.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
---
## Por Que OllaHub?
### Problemas Resolvidos
**Fragmentação de Ferramentas**
Elimina necessidade de múltiplas aplicações para gerenciar diferentes modelos IA e integrações contextuais.
**Dependência de Cloud**
Remove necessidade de conexão com serviços externos, permitindo uso completo offline após instalação inicial.
**Complexidade de Integração**
Abstrai complexidade do protocolo MCP oferecendo interface intuitiva para conexão com servidores especializados.
**Privacidade de Dados**
Garante que dados sensíveis nunca deixem a máquina do usuário durante interações com modelos.
### Casos de Uso
**Desenvolvimento Assistido por IA**
Integração com sistema de arquivos via MCP permite análise contextual de código-fonte, sugestões de refatoração e documentação automática.
**Análise Documental Local**
Implementação de RAG com documentos locais para pesquisa semântica, síntese de informações e geração de resumos.
**Automação de Workflows**
Criação de scripts e automações com acesso controlado a APIs externas através de servidores MCP especializados.
**Pesquisa e Agregação**
Combinação de múltiplas fontes de dados como repositórios GitHub, bancos de dados locais e sistemas de arquivos para análises complexas.
---
## Arquitetura
### Stack Tecnológico
#### Backend
- Rust: Linguagem core garantindo segurança de memória e performance
- Tauri 2.0: Framework desktop multiplataforma
- Axum: Framework web assíncrono para servidor REST interno
- Tokio: Runtime assíncrono de alta performance
- Reqwest: Cliente HTTP com suporte a streaming
- SQLx/Sled: Persistência de dados com queries type-safe
- Serde: Serialização JSON type-safe
#### Frontend
- SvelteKit 2.x: Meta-framework reativo com roteamento integrado
- TypeScript 5.x: Type-safety no frontend
- TailwindCSS: Framework CSS utility-first
- shadcn-svelte: Componentes UI acessíveis e customizáveis
- Marked: Renderização de Markdown para respostas formatadas
#### Infraestrutura
- Ollama: Gerenciamento de modelos LLM locais
- Model Context Protocol: Protocolo de extensibilidade via servidores especializados
- GitHub Actions: CI/CD para builds multiplataforma
### Componentes Principais
**Módulo de Gerenciamento Ollama**
Responsável por toda comunicação com a API REST do Ollama, incluindo listagem de modelos, inicialização de conversas, streaming de respostas e gerenciamento de contexto conversacional.
**Cliente MCP**
Implementa protocolo completo para descoberta, conexão e comunicação com servidores MCP via transports STDIO e HTTP+SSE, seguindo especificação JSON-RPC 2.0.
**Sistema de Persistência**
Gerencia armazenamento local de configurações, histórico de conversas, preferências do usuário e cache de dados utilizando banco embarcado para consultas eficientes.
**Engine de Streaming**
Implementa protocolo de streaming bidirecional permitindo renderização progressiva na interface durante geração de respostas longas.
**Camada de Segurança**
Gerencia autenticação de servidores MCP, validação de certificados, isolamento de processos e aplicação de políticas de segurança.
---
## Roadmap de Desenvolvimento
### Fase 1: Validação MVP Web (4-6 semanas)
**Objetivo**: Validar arquitetura técnica e fluxos de experiência do usuário antes de investimento no desenvolvimento Tauri completo.
**Sprint 1-2: Fundação Backend**
Inicialização de projeto Axum com estrutura modular, implementação de cliente Ollama com streaming via Server-Sent Events, desenvolvimento de endpoints REST para listagem de modelos e chat completion, criação de testes unitários.
**Sprint 3-4: Interface Web**
Setup de projeto SvelteKit com TypeScript, desenvolvimento de componente de chat com área de mensagens e seletor de modelos, implementação de sistema de histórico com persistência em LocalStorage, deploy de teste para validação externa.
**Sprint 5-6: Iteração e Refinamento**
Testes de usabilidade com desenvolvedores e entusiastas de IA, coleta de feedback qualitativo e quantitativo, otimização de performance do streaming, documentação de lições aprendidas para informar desenvolvimento Tauri.
### Fase 2: Aplicação Desktop Tauri (6-8 semanas)
**Sprint 7-9: Migração para Tauri**
Inicialização de projeto Tauri 2.0 com template SvelteKit, migração de lógica Axum para Tauri Commands, implementação de sistema IPC completo, desenvolvimento de sistema de eventos para comunicação backend-frontend.
**Sprint 10-12: Integração MCP**
Implementação completa de cliente MCP seguindo especificação oficial, sistema de descoberta automática de servidores MCP instalados, gerenciamento de múltiplas conexões simultâneas, interface de configuração para adicionar/remover servidores.
**Sprint 13-14: Features Avançadas**
Sistema de templates de prompt para casos de uso comuns, implementação de RAG básico com embeddings, configurações avançadas de modelos, sistema de plugins para extensibilidade, exportação de conversas em múltiplos formatos.
### Fase 3: Polish e Lançamento (3-4 semanas)
**Sprint 15-16: Qualidade e Performance**
Testes de carga e stress, profiling detalhado identificando gargalos, otimizações targeted de latências críticas, implementação de telemetria anonimizada, code review completo.
**Sprint 17-18: Documentação e Release**
Documentação completa de usuário com guias de início rápido, documentação técnica para desenvolvedores, vídeos tutoriais, setup de CI/CD para builds automáticos, lançamento em GitHub Releases e AUR para Arch Linux.
---
## Requisitos do Sistema
### Requisitos Mínimos
**Sistema Operacional**
Linux com kernel 5.10 ou superior (prioritário), Windows 10/11 com WSL2 (suporte planejado)
**Hardware**
Processador: 4 cores físicos, Memória RAM: 8 GB, Armazenamento: 10 GB disponíveis, GPU: Recomendado NVIDIA com CUDA ou AMD com ROCm para aceleração
**Dependências**
Ollama instalado e configurado, Node.js 20 LTS ou superior para desenvolvimento, Rust toolchain 1.75 ou superior para compilação
### Requisitos Recomendados
Processador: 8 cores físicos ou superior, Memória RAM: 16 GB ou superior, GPU: NVIDIA RTX 3060 ou superior com 12GB VRAM, Armazenamento: SSD NVMe para melhor performance
---
## Instalação
### Pré-requisitos
Antes de instalar o OllaHub, certifique-se que o Ollama está instalado e funcionando corretamente. Faça download do Ollama no site oficial e verifique a instalação executando o comando para listar modelos disponíveis.
### Instalação via Binário Pré-compilado
Faça download do binário apropriado para seu sistema operacional na seção Releases do repositório GitHub. Para Linux, escolha entre formato DEB para distribuições baseadas em Debian/Ubuntu, RPM para distribuições baseadas em Red Hat/Fedora, ou AppImage para distribuição universal.
Após download, instale o pacote conforme método de seu sistema operacional ou execute o AppImage diretamente após conceder permissões de execução.
### Compilação a partir do Código-fonte
Clone o repositório do GitHub, instale as dependências do frontend utilizando gerenciador de pacotes Node.js, compile o backend Rust, e execute o build final que gerará binário otimizado para produção.
---
## Uso
### Primeira Execução
Ao iniciar o OllaHub pela primeira vez, a aplicação realizará verificação de conexão com Ollama local. Caso não detecte instância em execução, será exibido alerta com instruções para iniciar o serviço.
Após conexão estabelecida, interface principal exibirá lista de modelos disponíveis baixados no Ollama. Selecione modelo desejado e inicie nova conversa.
### Gerenciamento de Modelos
A seção de modelos permite visualizar todos modelos disponíveis instalados localmente, incluindo informações sobre tamanho, capacidades e versão. Para adicionar novos modelos, utilize comandos do Ollama diretamente no terminal, e atualize a lista na interface do OllaHub.
### Integração com Servidores MCP
Acesse seção de configurações para adicionar servidores MCP. Para servidores locais, configure caminho do executável e argumentos necessários para inicialização via STDIO. Para servidores remotos, configure URL do endpoint HTTP e credenciais de autenticação se necessário.
Após configuração, OllaHub estabelecerá conexão e descobrirá automaticamente ferramentas e recursos disponíveis. Estas ferramentas aparecerão na interface e poderão ser invocadas durante conversas com modelos.
### Templates e Workflows
Utilize biblioteca de templates pré-configurados para casos de uso comuns como análise de código, revisão de documentos, geração de documentação técnica e debugging assistido. Templates podem ser customizados e salvos para reutilização futura.
### Exportação de Conversas
Conversas podem ser exportadas em formato Markdown para documentação, JSON para processamento programático, ou texto simples para compartilhamento. Acesse menu de contexto da conversa e selecione formato desejado.
---
## Segurança e Privacidade
### Processamento Local
Todo processamento de inferência ocorre localmente através do Ollama. Nenhum dado é transmitido para servidores externos durante uso normal da aplicação.
### Isolamento de Servidores MCP
Servidores MCP executam em processos separados com comunicação via pipes, prevenindo comprometimento do processo principal. Para servidores não-confiáveis, recomenda-se execução em containers Docker para camada adicional de isolamento.
### Persistência de Dados
Histórico de conversas e configurações são armazenados localmente em diretório de dados do usuário. Dados são salvos em formato SQLite ou JSON dependendo da configuração, permitindo backup e migração simples.
### Auditoria
Sistema de logging estruturado registra todas operações sensíveis incluindo conexões com servidores MCP, invocações de ferramentas externas e modificações de configuração. Logs podem ser revisados para auditoria de segurança.
---
## Contribuindo
OllaHub é um projeto open source e contribuições são bem-vindas. Antes de contribuir, leia as diretrizes de contribuição no arquivo CONTRIBUTING.md do repositório.
### Áreas de Contribuição
**Desenvolvimento de Features**
Implementação de novas funcionalidades seguindo roadmap do projeto ou propondo melhorias não planejadas.
**Correção de Bugs**
Identificação e correção de problemas reportados na seção Issues do GitHub.
**Documentação**
Melhoria de documentação de usuário, criação de tutoriais, tradução para outros idiomas.
**Testes**
Desenvolvimento de testes unitários e de integração, testes de usabilidade, identificação de casos extremos.
**Servidores MCP**
Desenvolvimento de servidores MCP especializados para integração com ferramentas e serviços específicos.
### Processo de Contribuição
Faça fork do repositório, crie branch para sua feature ou correção, implemente mudanças com commits descritivos, adicione testes quando apropriado, atualize documentação refletindo mudanças, envie pull request com descrição detalhada das alterações.
Todas contribuições serão revisadas pelos mantenedores do projeto antes de merge. Feedback construtivo será fornecido para auxiliar no processo de aprovação.
---
## Licença
Este projeto é licenciado sob a licença MIT. Veja arquivo LICENSE para detalhes completos.
A licença MIT permite uso comercial, modificação, distribuição e uso privado, desde que atribuição original seja mantida.
---
## Suporte e Comunidade
### Canais de Suporte
**GitHub Issues**
Para reportar bugs, solicitar features ou discutir aspectos técnicos do projeto.
**GitHub Discussions**
Para perguntas gerais, compartilhamento de casos de uso, discussões sobre arquitetura e decisões de design.
**Discord Community** (em planejamento)
Para suporte em tempo real, discussões da comunidade e compartilhamento de configurações e workflows.
### Recursos Adicionais
**Documentação Oficial**
Guias detalhados disponíveis no diretório docs do repositório cobrindo instalação, configuração, desenvolvimento e troubleshooting.
**Wiki do Projeto**
Artigos comunitários sobre casos de uso específicos, integrações com ferramentas populares e otimizações de performance.
**Exemplos de Código** (futuro)
Repositório de exemplos demonstrando integração do OllaHub com diferentes servidores MCP e casos de uso avançados.
---
## Reconhecimentos
Este projeto utiliza e é inspirado por diversos projetos open source extraordinários:
- Ollama pela plataforma de gerenciamento de modelos LLM locais
- Anthropic pelo desenvolvimento do Model Context Protocol
- Tauri pela framework de aplicações desktop com Rust
- SvelteKit pelo framework web reativo e performático
- Comunidade Rust pela linguagem e ecossistema de ferramentas excepcionais
Agradecimentos especiais aos early adopters e contribuidores que forneceram feedback valioso durante desenvolvimento do MVP.
---
## Status do Projeto
**Fase Atual**: MVP Web em desenvolvimento (Fase 1 - Sprint 3)
**Próximos Marcos**:
- Conclusão de testes de usabilidade do MVP web (2 semanas)
- Início da migração para Tauri (4 semanas)
- Implementação completa do cliente MCP (8 semanas)
- Lançamento da versão alpha para Linux (12 semanas)
Acompanhe progresso do desenvolvimento na seção Projects do GitHub e participe das discussões sobre features futuras.
---
**Desenvolvido com foco em privacidade, performance e experiência do usuário.**
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest } from "next/server";
import { chatWithStream } from "@/lib/ollama";
import { convertToOllamaMessages } from "@/lib/chat";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
try {
const { model, messages, options, system } = await req.json();
if (!model || !messages?.length) {
return new Response("Missing model or messages", { status: 400 });
}
const stream = new ReadableStream({
async start(controller) {
try {
const ollamaMessages = convertToOllamaMessages(messages);
const response = await chatWithStream(
model,
ollamaMessages,
options,
system
);
for await (const chunk of response) {
const token = chunk.message?.content || "";
if (token) {
controller.enqueue(
new TextEncoder().encode(JSON.stringify({ token }) + "\n")
);
}
}
controller.enqueue(
new TextEncoder().encode(JSON.stringify({ done: true }) + "\n")
);
controller.close();
} catch (error) {
console.error("Chat streaming error:", error);
controller.enqueue(
new TextEncoder().encode(
JSON.stringify({ error: "Failed to generate response" }) + "\n"
)
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (error) {
console.error("Chat API error:", error);
return new Response("Internal server error", { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { listModelsViaSdk, listModelsViaHttp } from "@/lib/ollama";
export const dynamic = "force-dynamic";
export async function GET() {
try {
try {
await listModelsViaSdk();
return NextResponse.json({ ok: true });
} catch {
await listModelsViaHttp();
return NextResponse.json({ ok: true });
}
} catch {
return NextResponse.json({ ok: false });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { listModelsViaSdk, listModelsViaHttp } from "@/lib/ollama";
import {
estimateCpuVram,
inferDevice,
inferQuantization,
mockModels,
type ModelInfo,
} from "@/lib/models";
export const dynamic = "force-dynamic";
function normalizeModel(model: any): ModelInfo {
const name: string = model?.model || model?.name || "unknown";
const sizeBytes: number = model?.size || 0;
const sizeGB = Number(Math.max(0.1, sizeBytes / 1024 ** 3).toFixed(2));
const quantization = inferQuantization(name);
const device = inferDevice(model?.details || name);
const { cpuPct, vramGB } = estimateCpuVram({ sizeGB, quantization, device });
return {
id: name,
name,
sizeGB,
quantization,
device,
estCpuUsagePct: cpuPct,
estVramGB: vramGB,
};
}
export async function GET() {
try {
let models: any[] = [];
try {
models = await listModelsViaSdk();
} catch {
models = await listModelsViaHttp();
}
const normalized = models.map((m) => normalizeModel(m));
return NextResponse.json({ offline: false, models: normalized });
} catch (e) {
return NextResponse.json({ offline: true, models: mockModels });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest } from "next/server";
import { pullModel } from "@/lib/ollama";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
try {
const { model } = await req.json();
if (!model) {
return new Response("Missing model", { status: 400 });
}
const stream = new ReadableStream({
async start(controller) {
try {
const response = await pullModel(model);
for await (const chunk of response) {
controller.enqueue(
new TextEncoder().encode(JSON.stringify(chunk) + "\n"),
);
}
controller.enqueue(
new TextEncoder().encode(
JSON.stringify({ status: "success" }) + "\n",
),
);
controller.close();
} catch (error) {
console.error("Pull streaming error:", error);
controller.enqueue(
new TextEncoder().encode(
JSON.stringify({ error: "Failed to pull model" }) + "\n",
),
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (error) {
console.error("Pull API error:", error);
return new Response("Internal server error", { status: 500 });
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+33
View File
@@ -0,0 +1,33 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #0b0c0f;
--accent: #2563eb; /* blue-600 */
--accent-foreground: #ffffff;
--border: #e5e7ef;
--surface: #f6f7fb;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-accent: var(--accent);
--color-border: var(--border);
--font-sans: var(--font-inter);
}
.dark {
--background: #0b0c0f;
--foreground: #e6e8ef;
--accent: #3b82f6; /* blue-500 */
--accent-foreground: #0b0c0f;
--border: #2a2f3a;
--surface: #12141a;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-inter), ui-sans-serif, system-ui;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "../components/theme-provider";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} antialiased`}>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { ModeToggle } from "@/components/mode-toggle";
import { ChatContainer } from "@/components/chat/ChatContainer";
import { ClientHome } from "@/components/ClientHome";
async function fetchModels() {
try {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
const res = await fetch(`${baseUrl}/api/ollama/models`, {
cache: "no-store",
});
if (!res.ok) return { offline: true, models: [] };
return res.json();
} catch {
return { offline: true, models: [] };
}
}
export default async function Home() {
const { offline, models } = await fetchModels();
return <ClientHome offline={offline} models={models} />;
}
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off"
}
},
"domains": {
"next": "recommended",
"react": "recommended"
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import { ModeToggle } from "@/components/mode-toggle";
import { ChatContainer } from "@/components/chat/ChatContainer";
import {
SettingsButton,
SettingsModal,
} from "@/components/settings/SettingsModal";
interface ClientHomeProps {
offline: boolean;
models: any[];
}
export function ClientHome({ offline, models }: ClientHomeProps) {
const [open, setOpen] = useState(false);
return (
<div className="h-screen flex flex-col bg-[var(--background)]">
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_oklab,var(--background),black_3%)]/80 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_3%)]/60">
<div className="mx-auto flex max-w-full items-center justify-between px-4 py-3">
<h1 className="text-lg font-semibold tracking-tight">Ollahub</h1>
<div className="flex items-center gap-3">
{offline && (
<span className="rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-xs">
Ollama offline
</span>
)}
<SettingsButton onOpen={() => setOpen(true)} />
<ModeToggle />
</div>
</div>
</header>
<main className="flex-1 overflow-hidden">
<ChatContainer models={models} offline={offline} />
</main>
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { ModelDropdown } from "@/components/model/ModelDropdown";
import { ModelPullDialog } from "@/components/model/ModelPullDialog";
import { ChatMessage, ChatInput } from "@/components/chat/ChatMessage";
import { SystemPrompt } from "@/components/chat/SystemPrompt";
import type { ModelInfo } from "@/lib/models";
import type { Message } from "@/lib/chat";
import { createMessage } from "@/lib/chat";
import { useUserPrefs, buildOllamaOptions } from "@/hooks/useUserPrefs";
interface ChatContainerProps {
models: ModelInfo[];
offline: boolean;
}
export function ChatContainer({ models, offline }: ChatContainerProps) {
const { prefs, ready } = useUserPrefs();
const [messages, setMessages] = useState<Message[]>([]);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const abortRef = useRef<AbortController | null>(null);
const [showPullDialog, setShowPullDialog] = useState(false);
const [modelToPull, setModelToPull] = useState<string>("");
const [systemPrompt, setSystemPrompt] = useState<string>("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent]);
const handleSendMessage = async (content: string) => {
if (!selectedModel || isStreaming) return;
const userMessage = createMessage("user", content);
const newMessages = [...messages, userMessage];
// Add system prompt if it exists and this is the first user message
const messagesToSend =
systemPrompt && messages.length === 0
? [createMessage("system", systemPrompt), ...newMessages]
: newMessages;
setMessages(newMessages);
setIsStreaming(true);
setStreamingContent("");
try {
const options = buildOllamaOptions(
prefs.device,
prefs.numGpu,
prefs.gpuIndex
);
abortRef.current = new AbortController();
const response = await fetch("/api/ollama/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: selectedModel,
messages: messagesToSend,
options,
system: systemPrompt || undefined,
}),
signal: abortRef.current.signal,
});
if (!response.ok) {
throw new Error("Failed to send message");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
let assistantContent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.token) {
assistantContent += data.token;
setStreamingContent(assistantContent);
}
if (data.done) {
const assistantMessage = createMessage(
"assistant",
assistantContent
);
setMessages((prev) => [...prev, assistantMessage]);
setStreamingContent("");
}
if (data.error) {
throw new Error(data.error);
}
} catch (e) {
console.error("Error parsing stream:", e);
}
}
}
} catch (error) {
console.error("Chat error:", error);
const errorMessage = createMessage(
"assistant",
"Sorry, I encountered an error. Please try again."
);
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsStreaming(false);
setStreamingContent("");
abortRef.current = null;
}
};
const handleStop = () => {
if (abortRef.current && isStreaming) {
abortRef.current.abort();
}
};
const handleSelectModel = (modelId: string) => {
const model = models.find((m) => m.id === modelId);
if (model) {
setSelectedModel(modelId);
// Optional: clear chat when switching models
// setMessages([]);
}
};
const handlePullModel = async (modelName: string) => {
try {
const response = await fetch("/api/ollama/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: modelName }),
});
if (!response.ok) {
throw new Error("Failed to pull model");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
if (data.status === "success") {
// Model pulled successfully, refresh models list
window.location.reload();
}
} catch (e) {
console.error("Error parsing pull stream:", e);
}
}
}
} catch (error) {
throw error;
}
};
const selectedModelData = models.find((m) => m.id === selectedModel);
return (
<div className="flex h-full bg-[var(--background)]">
{/* Sidebar */}
<div className="w-80 border-r border-[var(--border)] bg-[var(--surface)] p-4 overflow-y-auto">
<div className="mb-4">
<h2 className="text-sm font-medium text-[var(--foreground)]/80 mb-2">
Models
</h2>
<ModelDropdown
models={models}
selectedModel={selectedModel}
onSelectModel={handleSelectModel}
disabled={isStreaming}
/>
</div>
{offline && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
<p className="text-sm text-orange-800">
Ollama is offline. Using mock data.
</p>
</div>
)}
{selectedModelData && (
<div className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-3">
<h3 className="font-medium text-sm mb-2">Selected Model</h3>
<div className="space-y-1 text-xs text-[var(--foreground)]/80">
<div>Name: {selectedModelData.name}</div>
<div>Size: {selectedModelData.sizeGB} GB</div>
<div>Quantization: {selectedModelData.quantization}</div>
<div>Device: {selectedModelData.device}</div>
</div>
</div>
)}
<SystemPrompt onSystemPromptChange={setSystemPrompt} />
</div>
{/* Chat Area */}
<div className="flex-1 flex flex-col min-h-0">
{/* Chat Header */}
<div className="border-b border-[var(--border)] p-4 flex items-center justify-between gap-3">
<h1 className="text-lg font-semibold">
{selectedModelData
? `Chat with ${selectedModelData.name}`
: "Select a model to start chatting"}
</h1>
<div className="flex items-center gap-2">
<button
onClick={handleStop}
disabled={!isStreaming}
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
>
Stop
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto min-h-0">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-[var(--foreground)]/60">
<p className="text-lg mb-2">Welcome to Ollahub</p>
<p className="text-sm">Select a model and start chatting!</p>
</div>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{isStreaming && streamingContent && (
<ChatMessage
message={createMessage("assistant", streamingContent)}
isStreaming={true}
/>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input */}
<ChatInput
onSendMessage={handleSendMessage}
disabled={!selectedModel || isStreaming}
placeholder={
selectedModel ? "Type a message..." : "Select a model first..."
}
/>
</div>
{/* Pull Dialog */}
<ModelPullDialog
isOpen={showPullDialog}
onClose={() => setShowPullDialog(false)}
modelName={modelToPull}
onConfirmPull={handlePullModel}
/>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { User, Bot, Send, Loader2 } from "lucide-react";
import { MarkdownRenderer } from "@/components/message/MarkdownRenderer";
import { MessageActions } from "@/components/message/MessageActions";
import { extractCodeBlocks } from "@/hooks/useMarkdown";
import type { Message } from "@/lib/chat";
import { formatTimestamp } from "@/lib/chat";
interface ChatMessageProps {
message: Message;
isStreaming?: boolean;
}
export function ChatMessage({
message,
isStreaming = false,
}: ChatMessageProps) {
const isUser = message.role === "user";
return (
<div
className={`flex gap-3 p-4 ${isUser ? "justify-end" : "justify-start"}`}
>
{!isUser && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--accent)] text-white">
<Bot className="h-4 w-4" />
</div>
)}
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
isUser
? "bg-[var(--accent)] text-white"
: "bg-[var(--surface)] text-[var(--foreground)]"
}`}
>
<div className="whitespace-pre-wrap break-words">
{isUser ? (
message.content
) : (
<MarkdownRenderer content={message.content} />
)}
</div>
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
{!isUser && !isStreaming && (
<div className="mt-2">
<MessageActions
content={message.content}
extractCodeBlocks={extractCodeBlocks}
/>
</div>
)}
<div
className={`mt-1 text-xs opacity-70 ${
isUser ? "text-right" : "text-left"
}`}
>
{formatTimestamp(message.timestamp)}
</div>
</div>
{isUser && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--surface)]">
<User className="h-4 w-4" />
</div>
)}
</div>
);
}
interface ChatInputProps {
onSendMessage: (content: string) => void;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({
onSendMessage,
disabled = false,
placeholder = "Type a message...",
}: ChatInputProps) {
const [content, setContent] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (content.trim() && !disabled) {
onSendMessage(content.trim());
setContent("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${Math.min(
textareaRef.current.scrollHeight,
120
)}px`;
}
}, [content]);
return (
<form
onSubmit={handleSubmit}
className="flex gap-2 p-4 border-t border-[var(--border)]"
>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className="flex-1 resize-none rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)] disabled:opacity-50"
rows={1}
maxLength={2000}
/>
<button
type="submit"
disabled={!content.trim() || disabled}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
>
{disabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</form>
);
}
interface StreamingTextProps {
text: string;
isStreaming: boolean;
}
export function StreamingText({ text, isStreaming }: StreamingTextProps) {
return (
<span>
{text}
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
</span>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useState, useEffect } from "react";
import { Settings, Save, RotateCcw } from "lucide-react";
import { SystemPromptInfo } from "./SystemPromptInfo";
interface SystemPromptProps {
onSystemPromptChange: (prompt: string) => void;
}
export function SystemPrompt({ onSystemPromptChange }: SystemPromptProps) {
const [systemPrompt, setSystemPrompt] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const saved = localStorage.getItem("ollahub-system-prompt");
if (saved) {
setSystemPrompt(saved);
onSystemPromptChange(saved);
}
}, [onSystemPromptChange]);
const handleSave = () => {
localStorage.setItem("ollahub-system-prompt", systemPrompt);
onSystemPromptChange(systemPrompt);
setIsEditing(false);
setHasChanges(false);
};
const handleReset = () => {
const defaultPrompt = "";
setSystemPrompt(defaultPrompt);
localStorage.removeItem("ollahub-system-prompt");
onSystemPromptChange(defaultPrompt);
setIsEditing(false);
setHasChanges(false);
};
const handleChange = (value: string) => {
setSystemPrompt(value);
setHasChanges(true);
setIsEditing(true);
};
return (
<div className="mt-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-[var(--foreground)]/80">
System Prompt
</h3>
<div className="flex gap-1">
<SystemPromptInfo />
{isEditing && (
<>
<button
onClick={handleSave}
disabled={!hasChanges}
className="p-1 rounded hover:bg-[var(--background)] disabled:opacity-50"
title="Save prompt"
>
<Save className="h-3 w-3" />
</button>
<button
onClick={handleReset}
className="p-1 rounded hover:bg-[var(--background)]"
title="Reset to default"
>
<RotateCcw className="h-3 w-3" />
</button>
</>
)}
<button
onClick={() => setIsEditing(!isEditing)}
className="p-1 rounded hover:bg-[var(--background)]"
title="Edit system prompt"
>
<Settings className="h-3 w-3" />
</button>
</div>
</div>
{isEditing ? (
<div className="space-y-2">
<textarea
value={systemPrompt}
onChange={(e) => handleChange(e.target.value)}
placeholder="Enter system prompt (optional)..."
className="w-full h-24 resize-none rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
<p className="text-xs text-[var(--foreground)]/60">
System prompts help define the AI's behavior and personality.
</p>
</div>
) : (
<div className="rounded-md border border-[var(--border)] bg-[var(--background)] p-3">
{systemPrompt ? (
<p className="text-xs text-[var(--foreground)]/80 whitespace-pre-wrap">
{systemPrompt}
</p>
) : (
<p className="text-xs text-[var(--foreground)]/60 italic">
No system prompt set
</p>
)}
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { Info } from "lucide-react";
import { useState } from "react";
export function SystemPromptInfo() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="p-1 rounded hover:bg-[var(--background)]"
title="About system prompts"
>
<Info className="h-3 w-3" />
</button>
{isOpen && (
<div className="absolute top-6 left-0 z-50 w-80 rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 shadow-lg">
<div className="text-xs space-y-2">
<h4 className="font-medium">System Prompts</h4>
<p>
System prompts define the AI's behavior, personality, and response
style. They are sent once at the beginning of each conversation.
</p>
<div className="space-y-1">
<p className="font-medium">Examples:</p>
<ul className="list-disc list-inside space-y-1 text-[var(--foreground)]/80">
<li>"You are a helpful coding assistant"</li>
<li>"Respond in Portuguese, be concise"</li>
<li>"You are a creative writing coach"</li>
</ul>
</div>
<p className="text-[var(--foreground)]/60">
Based on Ollama documentation - system messages help guide model
behavior.
</p>
</div>
<button
onClick={() => setIsOpen(false)}
className="absolute top-2 right-2 p-1 rounded hover:bg-[var(--surface)]"
>
×
</button>
</div>
)}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeHighlight from "rehype-highlight";
type MarkdownRendererProps = {
content: string;
};
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeHighlight]}
>
{content}
</ReactMarkdown>
);
}
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { Copy, Edit3, RefreshCw, Check } from "lucide-react";
type MessageActionsProps = {
content: string;
disabled?: boolean;
onEdit?: () => void;
onRegenerate?: () => void;
extractCodeBlocks?: (
text: string
) => Array<{ language: string; code: string }>;
};
export function MessageActions({
content,
disabled,
onEdit,
onRegenerate,
extractCodeBlocks,
}: MessageActionsProps) {
const [copied, setCopied] = useState<"none" | "markdown" | "text" | "code">(
"none"
);
const copy = async (text: string, flag: MessageActionsProps["content"]) => {
await navigator.clipboard.writeText(text);
setCopied(flag as any);
setTimeout(() => setCopied("none"), 1500);
};
const stripMarkdown = (md: string) =>
md
.replace(/```[\s\S]*?```/g, (block) =>
block.replace(/```[\s\S]*?\n/, "").replace(/```$/, "")
)
.replace(/[#*_>`~-]/g, "");
const codeBlocks = extractCodeBlocks ? extractCodeBlocks(content) : [];
return (
<div className="flex items-center gap-1 opacity-70">
<button
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
onClick={() => copy(content, "markdown")}
disabled={disabled}
aria-label="Copy as markdown"
>
{copied === "markdown" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}{" "}
MD
</button>
<button
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
onClick={() => copy(stripMarkdown(content), "text")}
disabled={disabled}
aria-label="Copy as plain text"
>
{copied === "text" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}{" "}
TXT
</button>
{codeBlocks.map((b, i) => (
<button
key={`${b.language}-${i}`}
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
onClick={() => copy(b.code, "code")}
disabled={disabled}
aria-label={`Copy ${b.language} code`}
>
{copied === "code" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}{" "}
{b.language}
</button>
))}
{onEdit && (
<button
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
onClick={onEdit}
disabled={disabled}
aria-label="Edit message"
>
<Edit3 className="h-3 w-3" /> Edit
</button>
)}
{onRegenerate && (
<button
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
onClick={onRegenerate}
disabled={disabled}
aria-label="Regenerate"
>
<RefreshCw className="h-3 w-3" /> Retry
</button>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
export function ModeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
const isDark =
theme === "dark" ||
(!theme &&
typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
return (
<button
aria-label="Alternar tema"
className="inline-flex h-9 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 text-sm transition-colors hover:bg-[color-mix(in_oklab,var(--surface),black_6%)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
<span className="hidden sm:inline">{isDark ? "Light" : "Dark"}</span>
</button>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { Cpu, CircuitBoard, Info } from "lucide-react";
import type { ModelInfo } from "@/lib/models";
import { useState } from "react";
export function ModelCard({ model }: { model: ModelInfo }) {
const [hover, setHover] = useState(false);
const isGpu = model.device === "GPU";
return (
<div
role="button"
tabIndex={0}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onFocus={() => setHover(true)}
onBlur={() => setHover(false)}
className="group relative rounded-lg border border-[var(--border)] bg-[var(--background)] p-4 shadow-sm transition-all hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[16px] font-semibold tracking-tight">
{model.name}
</h3>
<span className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--foreground)]/80">
{isGpu ? (
<CircuitBoard className="h-3.5 w-3.5" />
) : (
<Cpu className="h-3.5 w-3.5" />
)}
{model.device}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--foreground)]/80">
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
{model.quantization}
</span>
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
{model.sizeGB} GB
</span>
</div>
<div className="pointer-events-none absolute right-3 top-3 opacity-60">
<Info className="h-4 w-4" />
</div>
{hover && (
<div className="absolute left-0 top-full z-10 mt-2 w-72 rounded-md border border-[var(--border)] bg-[var(--background)] p-3 text-sm shadow-lg">
<div className="mb-2 font-medium">Detalhes (estimativa)</div>
<div className="flex items-center justify-between">
<span>Uso de CPU</span>
<span className="font-medium">{model.estCpuUsagePct ?? "—"}%</span>
</div>
<div className="mt-1 flex items-center justify-between">
<span>Uso de VRAM</span>
<span className="font-medium">{model.estVramGB ?? 0} GB</span>
</div>
</div>
)}
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { ChevronDown, Cpu, CircuitBoard } from "lucide-react";
import type { ModelInfo } from "@/lib/models";
interface ModelDropdownProps {
models: ModelInfo[];
selectedModel: string | null;
onSelectModel: (modelId: string) => void;
disabled?: boolean;
}
export function ModelDropdown({
models,
selectedModel,
onSelectModel,
disabled = false,
}: ModelDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedModelData = models.find((m) => m.id === selectedModel);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
className="flex w-full items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
>
<div className="flex items-center gap-2">
{selectedModelData ? (
<>
<span className="truncate">{selectedModelData.name}</span>
<span className="flex items-center gap-1 rounded-md bg-[var(--surface)] px-2 py-0.5 text-xs">
{selectedModelData.device === "GPU" ? (
<CircuitBoard className="h-3 w-3" />
) : (
<Cpu className="h-3 w-3" />
)}
{selectedModelData.device}
</span>
</>
) : (
<span className="text-[var(--foreground)]/60">
Select a model...
</span>
)}
</div>
<ChevronDown
className={`h-4 w-4 transition-transform ${
isOpen ? "rotate-180" : ""
}`}
/>
</button>
{isOpen && (
<div className="absolute top-full z-50 mt-1 w-full rounded-lg border border-[var(--border)] bg-[var(--background)] shadow-lg">
<div className="max-h-60 overflow-y-auto">
{models.length === 0 ? (
<div className="px-3 py-2 text-sm text-[var(--foreground)]/60">
No models available
</div>
) : (
models.map((model) => (
<button
key={model.id}
onClick={() => {
onSelectModel(model.id);
setIsOpen(false);
}}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--surface)]"
>
<div className="flex flex-col items-start gap-1">
<span className="font-medium">{model.name}</span>
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60">
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
{model.quantization}
</span>
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
{model.sizeGB} GB
</span>
<span className="flex items-center gap-1">
{model.device === "GPU" ? (
<CircuitBoard className="h-3 w-3" />
) : (
<Cpu className="h-3 w-3" />
)}
{model.device}
</span>
</div>
</div>
</button>
))
)}
</div>
</div>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { ModelCard } from "./ModelCard";
import type { ModelInfo } from "@/lib/models";
export function ModelList({ models }: { models: ModelInfo[] }) {
if (!models?.length) {
return (
<div className="rounded-lg border border-[var(--border)] p-6 text-sm text-[var(--foreground)]/70">
Nenhum modelo encontrado.
</div>
);
}
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{models.map((m) => (
<ModelCard key={m.id} model={m} />
))}
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useState, useEffect } from "react";
import { X, Download, Loader2 } from "lucide-react";
interface ModelPullDialogProps {
isOpen: boolean;
onClose: () => void;
modelName: string;
onConfirmPull: (modelName: string) => Promise<void>;
}
interface PullProgress {
status: string;
digest?: string;
total?: number;
completed?: number;
error?: string;
}
export function ModelPullDialog({
isOpen,
onClose,
modelName,
onConfirmPull,
}: ModelPullDialogProps) {
const [isPulling, setIsPulling] = useState(false);
const [progress, setProgress] = useState<PullProgress | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isOpen) {
setIsPulling(false);
setProgress(null);
setError(null);
}
}, [isOpen]);
const handleConfirm = async () => {
setIsPulling(true);
setError(null);
try {
await onConfirmPull(modelName);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to pull model");
} finally {
setIsPulling(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-md rounded-lg border border-[var(--border)] bg-[var(--background)] p-6 shadow-lg">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Install Model</h2>
<button
onClick={onClose}
disabled={isPulling}
className="rounded-md p-1 transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mb-4">
<p className="text-sm text-[var(--foreground)]/80 mb-2">
Install <span className="font-medium">{modelName}</span>?
</p>
<p className="text-xs text-[var(--foreground)]/60">
This will download the model to your local Ollama installation.
</p>
</div>
{progress && (
<div className="mb-4 rounded-lg bg-[var(--surface)] p-3">
<div className="flex items-center justify-between text-sm mb-2">
<span>Downloading...</span>
<span>
{progress.total && progress.completed
? `${Math.round(
(progress.completed / progress.total) * 100,
)}%`
: "0%"}
</span>
</div>
<div className="w-full bg-[var(--border)] rounded-full h-2">
<div
className="bg-[var(--accent)] h-2 rounded-full transition-all duration-300"
style={{
width:
progress.total && progress.completed
? `${(progress.completed / progress.total) * 100}%`
: "0%",
}}
/>
</div>
{progress.digest && (
<p className="text-xs text-[var(--foreground)]/60 mt-1">
{progress.digest.slice(0, 12)}...
</p>
)}
</div>
)}
{error && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="flex gap-2 justify-end">
<button
onClick={onClose}
disabled={isPulling}
className="px-4 py-2 text-sm rounded-md border border-[var(--border)] transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
>
Cancel
</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>
</div>
</div>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useEffect, useState } from "react";
import { X, Settings } from "lucide-react";
import { useUserPrefs, type DevicePref } from "@/hooks/useUserPrefs";
export function SettingsButton({ onOpen }: { onOpen: () => void }) {
return (
<button
onClick={onOpen}
aria-label="Open settings"
className="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-[var(--surface)] px-3 text-sm transition-colors hover:bg-[color-mix(in_oklab,var(--surface),black_6%)] focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">Settings</span>
</button>
);
}
export function SettingsModal({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) {
const { prefs, ready, update } = useUserPrefs();
const [tab, setTab] = useState<"general" | "execution">("general");
const [localPrompt, setLocalPrompt] = useState("");
const [device, setDevice] = useState<DevicePref>("auto");
const [gpuIndex, setGpuIndex] = useState(0);
const [numGpu, setNumGpu] = useState(1);
useEffect(() => {
if (!ready) return;
setLocalPrompt(prefs.systemPrompt);
setDevice(prefs.device);
setGpuIndex(prefs.gpuIndex);
setNumGpu(prefs.numGpu);
}, [ready, prefs]);
const handleSave = () => {
update({ systemPrompt: localPrompt, device, gpuIndex, numGpu });
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-xl rounded-lg border border-border bg-background p-4 shadow-lg">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold">Settings</h2>
<button
onClick={onClose}
className="rounded p-1 hover:bg-[var(--surface)]"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mb-4 flex gap-2 border-b border-border">
<button
className={`px-3 py-2 text-sm ${
tab === "general"
? "border-b-2 border-accent font-medium"
: "text-[var(--foreground)]/60"
}`}
onClick={() => setTab("general")}
>
General
</button>
<button
className={`px-3 py-2 text-sm ${
tab === "execution"
? "border-b-2 border-accent font-medium"
: "text-[var(--foreground)]/60"
}`}
onClick={() => setTab("execution")}
>
Execution
</button>
</div>
{tab === "general" && (
<div className="space-y-2">
<label className="text-sm font-medium">System Prompt</label>
<textarea
value={localPrompt}
onChange={(e) => setLocalPrompt(e.target.value)}
placeholder="Define how the AI should behave..."
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
<p className="text-xs text-[var(--foreground)]/60">
Sent at the start of a conversation to guide model behavior.
</p>
</div>
)}
{tab === "execution" && (
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Device</label>
<div className="flex gap-2">
{(["auto", "cpu", "gpu"] as DevicePref[]).map((d) => (
<button
key={d}
onClick={() => setDevice(d)}
className={`rounded-md border border-border px-3 py-1 text-sm ${
device === d
? "bg-[var(--surface)]"
: "hover:bg-[var(--surface)]"
}`}
>
{d.toUpperCase()}
</button>
))}
</div>
<p className="text-xs text-[var(--foreground)]/60">
CPU best-effort via num_gpu:0. For hard CPU-only, set
CUDA_VISIBLE_DEVICES=-1 before starting Ollama.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium">
GPU Index (main_gpu)
</label>
<input
type="number"
value={gpuIndex}
onChange={(e) => setGpuIndex(Number(e.target.value))}
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"
min={0}
/>
</div>
<div>
<label className="text-sm font-medium">
Num GPUs (num_gpu)
</label>
<input
type="number"
value={numGpu}
onChange={(e) => setNumGpu(Number(e.target.value))}
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"
min={0}
/>
</div>
</div>
<p className="text-xs text-[var(--foreground)]/60">
Auto: no options sent. GPU: sends num_gpu/main_gpu. CPU: sends
num_gpu:0.
</p>
</div>
)}
<div className="mt-6 flex justify-end gap-2">
<button
onClick={onClose}
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-[var(--surface)]"
>
Cancel
</button>
<button
onClick={handleSave}
className="rounded-md bg-accent px-4 py-2 text-sm text-white hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
>
Save
</button>
</div>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
{...props}
>
{children}
</NextThemesProvider>
);
}
+27
View File
@@ -0,0 +1,27 @@
export function extractCodeBlocks(
text: string
): Array<{ language: string; code: string }> {
const regex = /```(\w+)?\n([\s\S]*?)```/g;
const blocks: Array<{ language: string; code: string }> = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const language = match[1] || "text";
const code = match[2]?.trimEnd() || "";
blocks.push({ language, code });
}
return blocks;
}
export function stripMarkdown(md: string): string {
// Remove code blocks first
let plain = md.replace(/```[\s\S]*?```/g, (block) =>
block.replace(/```[\s\S]*?\n/, "").replace(/```$/, "")
);
// Remove inline code
plain = plain.replace(/`([^`]+)`/g, "$1");
// Remove common markdown syntax
plain = plain
.replace(/(^|\s)[#>*_~`-]+/g, " ")
.replace(/\[(.*?)\]\((.*?)\)/g, "$1");
return plain;
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useCallback, useEffect, useState } from "react";
export type DevicePref = "auto" | "cpu" | "gpu";
type Prefs = {
systemPrompt: string;
device: DevicePref;
gpuIndex: number; // main_gpu
numGpu: number; // num_gpu
};
const KEYS = {
systemPrompt: "ollahub-system-prompt",
device: "ollahub-device-pref",
gpuIndex: "ollahub-gpu-index",
numGpu: "ollahub-num-gpu",
} as const;
const defaultPrefs: Prefs = {
systemPrompt: "",
device: "auto",
gpuIndex: 0,
numGpu: 1,
};
export function useUserPrefs() {
const [prefs, setPrefs] = useState<Prefs>(defaultPrefs);
const [ready, setReady] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const p: Prefs = {
systemPrompt:
localStorage.getItem(KEYS.systemPrompt) || defaultPrefs.systemPrompt,
device:
(localStorage.getItem(KEYS.device) as DevicePref) ||
defaultPrefs.device,
gpuIndex: Number(
localStorage.getItem(KEYS.gpuIndex) || defaultPrefs.gpuIndex
),
numGpu: Number(localStorage.getItem(KEYS.numGpu) || defaultPrefs.numGpu),
};
setPrefs(p);
setReady(true);
}, []);
const update = useCallback((next: Partial<Prefs>) => {
setPrefs((prev) => {
const merged: Prefs = { ...prev, ...next };
if (typeof window !== "undefined") {
localStorage.setItem(KEYS.systemPrompt, merged.systemPrompt);
localStorage.setItem(KEYS.device, merged.device);
localStorage.setItem(KEYS.gpuIndex, String(merged.gpuIndex));
localStorage.setItem(KEYS.numGpu, String(merged.numGpu));
}
return merged;
});
}, []);
return { prefs, ready, update };
}
export function buildOllamaOptions(
device: DevicePref,
numGpu: number,
gpuIndex: number
) {
if (device === "auto") return undefined;
if (device === "cpu") return { num_gpu: 0 } as const;
// gpu
return {
num_gpu: Math.max(1, numGpu),
main_gpu: Math.max(0, gpuIndex),
} as const;
}
+50
View File
@@ -0,0 +1,50 @@
export type ChatRole = "user" | "assistant" | "system";
export type Message = {
id: string;
role: ChatRole;
content: string;
timestamp: number;
};
export type ChatState = {
messages: Message[];
selectedModel: string | null;
isStreaming: boolean;
streamingContent: string;
};
export function createMessage(role: ChatRole, content: string): Message {
return {
id: crypto.randomUUID(),
role,
content,
timestamp: Date.now(),
};
}
export function formatTimestamp(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "agora";
if (minutes < 60) return `${minutes} min atrás`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h atrás`;
const days = Math.floor(hours / 24);
return `${days}d atrás`;
}
export function convertToOllamaMessages(messages: Message[]) {
return messages.map((msg) => ({
role: msg.role,
content: msg.content,
}));
}
export function createSystemMessage(content: string): Message {
return createMessage("system", content);
}
+100
View File
@@ -0,0 +1,100 @@
export type Device = "CPU" | "GPU";
export type ModelInfo = {
id: string;
name: string;
sizeGB: number;
quantization: string;
device: Device;
estCpuUsagePct?: number;
estVramGB?: number;
};
export const mockModels: ModelInfo[] = [
{
id: "llama3.1-8b-q4",
name: "llama3.1:8b-q4",
sizeGB: 4.8,
quantization: "Q4",
device: "CPU",
estCpuUsagePct: 85,
estVramGB: 0.0,
},
{
id: "llama3.1-8b-q8",
name: "llama3.1:8b-q8",
sizeGB: 8.9,
quantization: "Q8",
device: "GPU",
estCpuUsagePct: 20,
estVramGB: 8,
},
{
id: "mistral-7b-q4",
name: "mistral:7b-q4",
sizeGB: 4.1,
quantization: "Q4",
device: "CPU",
estCpuUsagePct: 75,
estVramGB: 0,
},
{
id: "mistral-7b-q8",
name: "mistral:7b-q8",
sizeGB: 7.9,
quantization: "Q8",
device: "GPU",
estCpuUsagePct: 18,
estVramGB: 8,
},
{
id: "qwen2.5-7b-q4",
name: "qwen2.5:7b-q4",
sizeGB: 4.2,
quantization: "Q4",
device: "CPU",
estCpuUsagePct: 80,
estVramGB: 0,
},
{
id: "qwen2.5-7b-q8",
name: "qwen2.5:7b-q8",
sizeGB: 8.1,
quantization: "Q8",
device: "GPU",
estCpuUsagePct: 22,
estVramGB: 8,
},
];
export function inferQuantization(nameOrDetails: string | unknown): string {
const s =
typeof nameOrDetails === "string" ? nameOrDetails.toLowerCase() : "";
if (s.includes("q8")) return "Q8";
if (s.includes("q5")) return "Q5";
if (s.includes("q4")) return "Q4";
return "Q?";
}
export function inferDevice(details?: unknown): Device {
const s = typeof details === "string" ? details.toLowerCase() : "";
if (s.includes("cuda") || s.includes("metal") || s.includes("gpu"))
return "GPU";
return "CPU";
}
export function estimateCpuVram(
info: Pick<ModelInfo, "sizeGB" | "quantization" | "device">,
) {
const q = info.quantization.toUpperCase();
let cpuPct = 50;
let vramGB = 0;
if (info.device === "GPU") {
vramGB = Math.max(4, Math.round(info.sizeGB));
cpuPct = 20;
} else {
// heurística simples: quantizações mais baixas usam mais CPU
cpuPct = q === "Q8" ? 60 : q === "Q5" ? 70 : 85;
}
return { cpuPct, vramGB };
}
+42
View File
@@ -0,0 +1,42 @@
import { Ollama } from "ollama";
export const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434";
export async function listModelsViaSdk() {
try {
const client = new Ollama({ host: OLLAMA_HOST });
const res = await client.list();
return res?.models ?? [];
} catch (e) {
throw e;
}
}
export async function listModelsViaHttp() {
const url = `${OLLAMA_HOST}/api/tags`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`Ollama HTTP error ${res.status}`);
const data = await res.json();
return data?.models ?? [];
}
export async function chatWithStream(
model: string,
messages: Array<{ role: string; content: string }>,
options?: Record<string, unknown>,
system?: string
) {
const client = new Ollama({ host: OLLAMA_HOST });
return client.chat({
model,
messages,
stream: true,
options,
system,
});
}
export async function pullModel(model: string) {
const client = new Ollama({ host: OLLAMA_HOST });
return client.pull({ model, stream: true });
}
+30
View File
@@ -0,0 +1,30 @@
export const DEFAULT_SYSTEM_PROMPT = `Você é um assistente de IA especializado em desenvolvimento de software e análise técnica.
FORMATAÇÃO OBRIGATÓRIA:
- Sempre use blocos de código markdown com especificação de linguagem
- Para JavaScript/TypeScript: \`\`\`javascript ou \`\`\`typescript
- Para Python: \`\`\`python
- Para Bash/Shell: \`\`\`bash
- Para SQL: \`\`\`sql
- Para JSON: \`\`\`json
- Para YAML: \`\`\`yaml
- Para Docker: \`\`\`dockerfile
- Para configurações: \`\`\`toml, \`\`\`ini, \`\`\`conf
ESTRUTURA DE RESPOSTAS:
1. Explicação conceitual clara
2. Exemplo prático em bloco de código
3. Explicação do código quando necessário
4. Boas práticas
5. Possíveis problemas e soluções
CONTEXTO OLLAHUB:
- Execução local via Ollama
- Stack frequente: Next.js, React, Node.js
- Priorize exemplos executáveis e explique comandos e configurações.`;
export function mergeSystemPrompt(userPrompt?: string) {
if (!userPrompt || userPrompt.trim().length === 0)
return DEFAULT_SYSTEM_PROMPT;
return `${DEFAULT_SYSTEM_PROMPT}\n\nDiretivas do usuário:\n${userPrompt}`;
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+38
View File
@@ -0,0 +1,38 @@
{
"name": "ollahub",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome check",
"format": "biome format --write"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.548.0",
"next": "16.0.0",
"next-themes": "^0.3.0",
"ollama": "^0.5.11",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.0.0",
"refractor": "^3.6.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+2340
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -0,0 +1,2 @@
ignoredBuiltDependencies:
- sharp
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}