From a3f8226fda76c6a76db2917eeb9cbf287af6d739 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Tue, 28 Oct 2025 12:51:19 -0300 Subject: [PATCH 01/16] 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. --- .gitignore | 41 + README.md | 337 +--- app/api/ollama/chat/route.ts | 62 + app/api/ollama/health/route.ts | 18 + app/api/ollama/models/route.ts | 44 + app/api/ollama/pull/route.ts | 54 + app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 33 + app/layout.tsx | 25 + app/page.tsx | 21 + biome.json | 37 + components/ClientHome.tsx | 41 + components/chat/ChatContainer.tsx | 296 +++ components/chat/ChatMessage.tsx | 154 ++ components/chat/SystemPrompt.tsx | 111 ++ components/chat/SystemPromptInfo.tsx | 50 + components/message/MarkdownRenderer.tsx | 22 + components/message/MessageActions.tsx | 107 ++ components/mode-toggle.tsx | 27 + components/model/ModelCard.tsx | 61 + components/model/ModelDropdown.tsx | 116 ++ components/model/ModelList.tsx | 21 + components/model/ModelPullDialog.tsx | 145 ++ components/settings/SettingsModal.tsx | 177 ++ components/theme-provider.tsx | 20 + hooks/useMarkdown.ts | 27 + hooks/useUserPrefs.ts | 77 + lib/chat.ts | 50 + lib/models.ts | 100 + lib/ollama.ts | 42 + lib/systemPrompt.ts | 30 + next.config.ts | 7 + package.json | 38 + pnpm-lock.yaml | 2340 +++++++++++++++++++++++ pnpm-workspace.yaml | 2 + postcss.config.mjs | 7 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + tsconfig.json | 34 + 42 files changed, 4465 insertions(+), 314 deletions(-) create mode 100644 .gitignore create mode 100644 app/api/ollama/chat/route.ts create mode 100644 app/api/ollama/health/route.ts create mode 100644 app/api/ollama/models/route.ts create mode 100644 app/api/ollama/pull/route.ts create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 biome.json create mode 100644 components/ClientHome.tsx create mode 100644 components/chat/ChatContainer.tsx create mode 100644 components/chat/ChatMessage.tsx create mode 100644 components/chat/SystemPrompt.tsx create mode 100644 components/chat/SystemPromptInfo.tsx create mode 100644 components/message/MarkdownRenderer.tsx create mode 100644 components/message/MessageActions.tsx create mode 100644 components/mode-toggle.tsx create mode 100644 components/model/ModelCard.tsx create mode 100644 components/model/ModelDropdown.tsx create mode 100644 components/model/ModelList.tsx create mode 100644 components/model/ModelPullDialog.tsx create mode 100644 components/settings/SettingsModal.tsx create mode 100644 components/theme-provider.tsx create mode 100644 hooks/useMarkdown.ts create mode 100644 hooks/useUserPrefs.ts create mode 100644 lib/chat.ts create mode 100644 lib/models.ts create mode 100644 lib/ollama.ts create mode 100644 lib/systemPrompt.ts create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index d741acd..e215bc4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/api/ollama/chat/route.ts b/app/api/ollama/chat/route.ts new file mode 100644 index 0000000..1c94e3e --- /dev/null +++ b/app/api/ollama/chat/route.ts @@ -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 }); + } +} diff --git a/app/api/ollama/health/route.ts b/app/api/ollama/health/route.ts new file mode 100644 index 0000000..e26c6ff --- /dev/null +++ b/app/api/ollama/health/route.ts @@ -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 }); + } +} diff --git a/app/api/ollama/models/route.ts b/app/api/ollama/models/route.ts new file mode 100644 index 0000000..9bd465d --- /dev/null +++ b/app/api/ollama/models/route.ts @@ -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 }); + } +} diff --git a/app/api/ollama/pull/route.ts b/app/api/ollama/pull/route.ts new file mode 100644 index 0000000..7407e1a --- /dev/null +++ b/app/api/ollama/pull/route.ts @@ -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 }); + } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..9b92498 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..2ea6b33 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..17f5c3a --- /dev/null +++ b/app/page.tsx @@ -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 ; +} diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..41b3b95 --- /dev/null +++ b/biome.json @@ -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" + } + } + } +} diff --git a/components/ClientHome.tsx b/components/ClientHome.tsx new file mode 100644 index 0000000..3e6903c --- /dev/null +++ b/components/ClientHome.tsx @@ -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 ( +
+
+
+

Ollahub

+
+ {offline && ( + + Ollama offline + + )} + setOpen(true)} /> + +
+
+
+
+ +
+ setOpen(false)} /> +
+ ); +} diff --git a/components/chat/ChatContainer.tsx b/components/chat/ChatContainer.tsx new file mode 100644 index 0000000..88e4991 --- /dev/null +++ b/components/chat/ChatContainer.tsx @@ -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([]); + const [selectedModel, setSelectedModel] = useState(null); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(""); + const abortRef = useRef(null); + const [showPullDialog, setShowPullDialog] = useState(false); + const [modelToPull, setModelToPull] = useState(""); + const [systemPrompt, setSystemPrompt] = useState(""); + const messagesEndRef = useRef(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 ( +
+ {/* Sidebar */} +
+
+

+ Models +

+ +
+ + {offline && ( +
+

+ Ollama is offline. Using mock data. +

+
+ )} + + {selectedModelData && ( +
+

Selected Model

+
+
Name: {selectedModelData.name}
+
Size: {selectedModelData.sizeGB} GB
+
Quantization: {selectedModelData.quantization}
+
Device: {selectedModelData.device}
+
+
+ )} + + +
+ + {/* Chat Area */} +
+ {/* Chat Header */} +
+

+ {selectedModelData + ? `Chat with ${selectedModelData.name}` + : "Select a model to start chatting"} +

+
+ +
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+
+

Welcome to Ollahub

+

Select a model and start chatting!

+
+
+ ) : ( + <> + {messages.map((message) => ( + + ))} + {isStreaming && streamingContent && ( + + )} +
+ + )} +
+ + {/* Input */} + +
+ + {/* Pull Dialog */} + setShowPullDialog(false)} + modelName={modelToPull} + onConfirmPull={handlePullModel} + /> +
+ ); +} diff --git a/components/chat/ChatMessage.tsx b/components/chat/ChatMessage.tsx new file mode 100644 index 0000000..10aa5e6 --- /dev/null +++ b/components/chat/ChatMessage.tsx @@ -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 ( +
+ {!isUser && ( +
+ +
+ )} +
+
+ {isUser ? ( + message.content + ) : ( + + )} +
+ {isStreaming && ▊} + {!isUser && !isStreaming && ( +
+ +
+ )} +
+ {formatTimestamp(message.timestamp)} +
+
+ + {isUser && ( +
+ +
+ )} +
+ ); +} + +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(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 ( +
+