18 Commits
Author SHA1 Message Date
evandro f0b7db8c12 docs(readme): redesenha o README com hero em SVG [skip ci] 2026-10-24 04:57:52 -03:00
evandro 2835957e40 docs: redesign do README (padrão TwitterBR-MOD) — dados atualizados
Build and Release / build (appimage, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 6m25s
Build and Release / build (dmg, macos-latest, aarch64-apple-darwin) (push) Canceled after 0s
Build and Release / build (dmg, macos-latest, x86_64-apple-darwin) (push) Canceled after 0s
Build and Release / build (msi, windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Build and Release / create-release (push) Canceled after 0s
2026-10-13 21:50:58 -03:00
Evandro Fonseca Junior 3e57b78aa7 Merge pull request #7 from evandrodevbr:develop
Build and Release / build (appimage, ubuntu-latest, x86_64-unknown-linux-gnu) (push) Failing after 6m18s
Build and Release / build (dmg, macos-latest, aarch64-apple-darwin) (push) Canceled after 0s
Build and Release / build (dmg, macos-latest, x86_64-apple-darwin) (push) Canceled after 0s
Build and Release / build (msi, windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Build and Release / create-release (push) Canceled after 0s
chore: add 'develop' branch to release workflow triggers
2025-11-27 23:04:26 -03:00
Evandro Fonseca Junior cde8e5ac00 chore: add 'develop' branch to release workflow triggers
- Included 'develop' branch in the GitHub Actions release workflow to enable automated releases from the development branch, enhancing CI/CD processes.
2025-11-27 23:03:58 -03:00
Evandro Fonseca Junior 6fdbd4bfe6 chore: update pnpm workspace configuration
- Added root package to the pnpm workspace configuration to streamline dependency management across the project.
2025-11-27 23:02:59 -03:00
Evandro Fonseca Junior d01d26e74f Merge pull request #6 from evandrodevbr:develop
chore: streamline pnpm installation in release workflow
2025-11-27 22:59:06 -03:00
Evandro Fonseca Junior e51a54c568 Merge remote-tracking branch 'origin/master' into develop 2025-11-27 22:58:59 -03:00
Evandro Fonseca Junior a319a9cf8d chore: streamline pnpm installation in release workflow
- Removed duplicate pnpm installation steps in the GitHub Actions release workflow.
- Ensured consistent setup of pnpm across jobs to optimize workflow efficiency.
2025-11-27 22:58:29 -03:00
Evandro Fonseca Junior 1daa6c77ee Merge pull request #5 from evandrodevbr:develop
remove: delete the document "Estabilizar Scraper Rust (Browser Resurrection, Send e Retry)" as it is no longer needed for the project. This document contained analysis, context, and implementation plans for improving the scraper's resilience and functionality.
2025-11-27 22:54:28 -03:00
Evandro Fonseca Junior 3e5727ffc7 remove: delete the document "Estabilizar Scraper Rust (Browser Resurrection, Send e Retry)" as it is no longer needed for the project. This document contained analysis, context, and implementation plans for improving the scraper's resilience and functionality. 2025-11-27 22:53:55 -03:00
Evandro Fonseca Junior 162a6f37f7 Merge pull request #4 from evandrodevbr:app-automatic-att
feat: integrate automatic update checks and enhance settings UI
2025-11-27 22:48:06 -03:00
Evandro Fonseca Junior 6c366eecb0 feat: integrate automatic update checks and enhance settings UI
- Added functionality for automatic update checks in the settings page, allowing users to enable or disable this feature.
- Integrated app version display and update availability notifications.
- Updated settings store to manage auto-check updates state and handle version migration.
- Enhanced layout with a new card for update settings, improving user experience and accessibility.
2025-11-27 22:47:48 -03:00
Evandro Fonseca Junior 6cdc8d4294 Merge pull request #3 from evandrodevbr:optimizations
Optimizations
2025-11-27 21:53:56 -03:00
Evandro Fonseca Junior 0d19f496bb feat(chat): implement infinite scroll for message loading and optimize chat message rendering
- Added infinite scroll functionality to load older messages when the user scrolls near the top of the chat.
- Implemented pagination state management to track loading status and available messages.
- Enhanced ChatMessage component with React.memo for performance optimization during streaming.
- Updated database interactions to support paginated message retrieval, improving efficiency in loading chat history.
2025-11-27 21:53:27 -03:00
Evandro Fonseca Junior e53e49c8f8 feat(chat): implement speculative web search and embeddings commands
- Added speculative search functionality to enhance query processing by detecting when a web search may be needed based on user input patterns.
- Integrated commands for managing embeddings, including downloading models, checking availability, calculating relevance scores, generating embeddings, and pruning context.
- Optimized chat streaming by buffering tokens to reduce overhead and improve responsiveness.
- Implemented lazy initialization for the headless browser to conserve memory until needed.
- Enhanced web scraping capabilities with a static scraping method for faster content retrieval.
2025-11-27 21:19:16 -03:00
Evandro Fonseca Junior ba03131a78 feat(chat): enhance search functionality and message highlighting
- Added search match navigation to allow users to cycle through search results in chat sessions.
- Implemented highlighting of search terms in chat messages for better visibility.
- Updated sidebar to display match counts for sessions based on search results.
- Enhanced session search logic in the database to include match counts for improved user experience.
- Refactored chat message component to support dynamic highlighting of search terms.
2025-11-27 20:39:39 -03:00
Evandro Fonseca Junior 1a64f5aa18 Merge pull request #2 from evandrodevbr/full-rust-backend
Full-rust-backend
2025-11-27 18:46:03 -03:00
Evandro Fonseca Junior 7d169d8a61 Merge pull request #1 from evandrodevbr/develop
feat: enhance chat functionality with markdown support and default prompts
2025-11-19 00:40:26 -03:00
33 changed files with 3950 additions and 307 deletions

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
name: Build and Release
on:
push:
branches:
- main
- master
- develop
workflow_dispatch:
env:
TAURI_VERSION: "2.9.4"
jobs:
build:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: 'windows-latest'
target: 'x86_64-pc-windows-msvc'
bundle: 'msi'
- platform: 'macos-latest'
target: 'aarch64-apple-darwin'
bundle: 'dmg'
- platform: 'macos-latest'
target: 'x86_64-apple-darwin'
bundle: 'dmg'
- platform: 'ubuntu-latest'
target: 'x86_64-unknown-linux-gnu'
bundle: 'appimage'
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Install dependencies (Ubuntu only)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Generate changelog
run: pnpm generate-changelog
- name: Build Tauri app
run: |
cd src-tauri
cargo build --release --target ${{ matrix.target }}
- name: Build Tauri bundle
env:
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_UPDATER_PRIVATE_KEY }}
run: |
cd src-tauri
if [ -n "$TAURI_PRIVATE_KEY" ]; then
echo "$TAURI_PRIVATE_KEY" > /tmp/private.key
cargo tauri build --target ${{ matrix.target }} --config '{"updater":{"active":true}}'
rm /tmp/private.key
else
cargo tauri build --target ${{ matrix.target }}
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.platform }}-${{ matrix.target }}
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.${{ matrix.bundle }}
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.exe
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage
src-tauri/target/${{ matrix.target }}/release/bundle/**/*.deb
retention-days: 7
create-release:
needs: build
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
- name: Generate changelog for release
run: pnpm generate-changelog
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Extract release notes
id: extract_notes
run: |
if [ -f "data/changelog.json" ]; then
NOTES=$(node -e "
const fs = require('fs');
try {
const changelog = JSON.parse(fs.readFileSync('data/changelog.json', 'utf-8'));
const latestRelease = changelog.releases[0];
if (latestRelease) {
let notes = '## ' + (latestRelease.version === 'unreleased' ? 'Próxima Versão' : 'v' + latestRelease.version) + '\\n\\n';
notes += '**Data:** ' + latestRelease.date + '\\n\\n';
notes += '### Mudanças\\n\\n';
latestRelease.commits.forEach(commit => {
const icon = commit.type === 'feat' ? '✨' : '🐛';
notes += '- ' + icon + ' **' + (commit.type === 'feat' ? 'Nova Funcionalidade' : 'Correção') + ':** ' + commit.message + '\\n';
});
console.log(notes);
} else {
console.log('## Release ${{ steps.get_version.outputs.version }}');
}
} catch (e) {
console.log('## Release ${{ steps.get_version.outputs.version }}');
}
")
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
else
echo "notes=## Release ${{ steps.get_version.outputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.tag }}
name: Release ${{ steps.get_version.outputs.tag }}
body: |
${{ steps.extract_notes.outputs.notes }}
## Downloads
Baixe o instalador para sua plataforma nos assets abaixo.
draft: false
files: artifacts/**/*
generate_release_notes: false
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
~/.tauri/ollahub.key
@@ -1,114 +0,0 @@
## 📋 Análise
**Solicitação**: Corrigir falhas de conexão do browser headless e o erro de compilação `Send`, tornar o scraper resiliente com retry e ampliar resultados de busca.
**Objetivo real**: Garantir estabilidade contínua do backend de scraping, mesmo quando o Chrome headless cai, evitando violações de `Send` e melhorando a cobertura de fontes.
## 🔍 Contexto Identificado
### Arquivos Examinados
- `src-tauri/src/lib.rs:1176–1193` get_or_create_browser: cria/reutiliza `Browser` singleton.
- `src-tauri/src/lib.rs:1247–1277` search_web_metadata: comando Tauri que exige `Future + Send`.
- `src-tauri/src/web_scraper.rs:136–240` search_duckduckgo_metadata: parsing HTML, previously segurava `Html` atravessando `await`.
- `src-tauri/src/web_scraper.rs:469–538` search_and_scrape_with_config: coordena scraping paralelo e coleta resultados.
- `src-tauri/src/web_scraper.rs:605–736` fetch_and_convert_sync: cria aba, navega, extrai HTML e usa Readability.
- `src-tauri/src/lib.rs:1306–1399` force_kill_browser: encerra processos Chrome headless.
### Padrões Detectados
- **Singleton Browser** via `State<BrowserState>` com `Arc<Browser>`.
- **Concorrência controlada** com `Semaphore` e `tokio::spawn_blocking` para operações de Chrome.
- **Fallbacks**: limpeza de URL, filtros anti-ads, Readability + fallback por parágrafos.
### Dependências Mapeadas
```
web_scraper (modificado)
├─ headless_chrome::Browser/Tab
├─ reqwest (HTTP)
├─ scraper (Html, Selector) [não Send]
└─ readability, html2text
lib (modificado)
└─ tauri commands (Future + Send obrigatório)
```
## 🎯 Abordagens Viáveis
### Abordagem A: Verificação ativa de Browser + Retry cooperativo
**Conceito**: Validar liveness do `Browser` antes de uso; se cair durante scraping, recriar e repetir uma passada de URLs com limite de tentativas.
```
request → get_or_create_browser (is_alive?) → spawn scraping
↳ erro conn fechada → reset + recreate → retry restante (1x)
```
**Vantagens**: ✓ Simples de integrar, ✓ Minimiza impacto, ✓ Boa resiliência
**Desvantagens**: ✗ Retry global pode repetir algumas URLs
**Complexidade**: O(n) | Tempo: 3–4h | Risco: Médio
### Abordagem B: Pool/Auto-heal por task
**Conceito**: Cada task recupera o browser ao falhar e re-executa só sua URL.
```
spawn_blocking(url) → new_tab → erro conn → sinaliza reset → reobtem browser → reprocessa url
```
**Vantagens**: ✓ Isolamento por URL
**Desvantagens**: ✗ Coordenação de estado entre threads, ✗ Maior complexidade
**Complexidade**: O(n) | Tempo: 5–6h | Risco: Médio-Alto
## 🏆 Recomendação
**Abordagem A** porque:
- Alinha com o padrão singleton já existente.
- Menor acoplamento entre tarefas e estado global.
- Implementação direta com menor risco de condições de corrida.
## ⚠️ Riscos & Mitigações
| Risco | Prob | Impacto | Mitigação |
|-------|------|---------|-----------|
| Loop de retry infinito | Baixa | Médio | Limitar a 1–2 tentativas, backoff curto |
| Recriação concorrente do Browser | Média | Médio | Guardar lock no `BrowserState` e checar antes de recriar |
| Demasiadas abas simultâneas | Média | Médio | Reduzir `max_concurrent_tabs` ao detectar falha |
| DDG bloqueios 429 | Média | Baixo | Rotação User-Agent e backoff |
## 📝 Plano de Execução
1. Ajustes `Send` (consolidação)
- Isolar parsing `scraper::Html` em escopo síncrono que retorna `Vec<SearchResultMetadata>` (já aplicado em parte na função; revisar para todos os caminhos).
- Garantir que nenhum `Html`/`Selector` vive além do `await` nos métodos `search_*`.
- Verificar pontos: `src-tauri/src/web_scraper.rs:136–240` e demais que usem `Html`.
2. Browser Resurrection
- Adicionar `fn is_browser_alive(browser: &Browser) -> bool` em `web_scraper.rs` ou `lib.rs` (ex.: tentar `new_tab()` e descartar; ou `get_tabs()`; se falhar → false).
- Alterar `get_or_create_browser` (`src-tauri/src/lib.rs:1176–1193`) para: se `Some(browser)` e `!is_alive(browser)` → `reset` e `create_browser()`.
- Em `force_kill_browser` (`src-tauri/src/lib.rs:1306–1399`): após kill, setar `BrowserState` para `None` via lock (ou orientar UI a chamar `reset_browser` antes do kill), garantindo recriação no próximo uso.
3. Retry resiliente no Scraper
- Envolver `fetch_and_convert_sync` com camada de retry (máx. 2 tentativas) na orquestração async (`search_and_scrape_with_config` em `src-tauri/src/web_scraper.rs:469–538`).
- Se erro contiver "underlying connection is closed"/timeout global do browser:
- Executar `reset_browser` e `get_or_create_browser` para novo `Arc<Browser)`.
- Reprocessar as URLs que falharam apenas uma vez com o novo browser.
- Reduzir temporariamente `max_concurrent_tabs` para 3 ao detectar queda, evitando sobrecarga imediata.
4. Paginação no DuckDuckGo
- Em `search_duckduckgo` (`src-tauri/src/web_scraper.rs:78–133`): suportar paginação via parâmetro `s=<offset>` até atingir `limit` (30–50).
- Extrair links por página, deduplicar, respeitar `excluded_domains`/ads.
5. Telemetria e Logs
- Logar eventos de recreação de browser, tentativas de retry e motivos.
- Contadores: quedas de browser, tentativas, tempo médio por URL.
6. Testes/Validação
- Caso 1: Forçar `force_kill_browser` durante scraping → validar que a próxima chamada recria o browser e conclui.
- Caso 2: Simular DDG com >= 40 resultados → paginar e coletar sem violar `Send`.
- Caso 3: Stress com `max_concurrent_tabs=5` → se cair, reduzir e recuperar.
## Key Changes (Resumo de Implementação)
- `lib.rs:1176–1193`: `get_or_create_browser` verifica liveness; recria se morto.
- `lib.rs:1306–1399`: sincronia com `reset_browser` ao matar processos.
- `web_scraper.rs:469–538`: retry global (1x) e recriação de browser ao detectar conexão fechada; ajuste dinamicamente `Semaphore` na segunda passada.
- `web_scraper.rs:78–133`: paginação DDG com `s=` offset.
- `web_scraper.rs:136–240`: parsing isolado; garantir que `Html` é dropado antes do `await`.
## Entregáveis
- Código robusto em `web_scraper.rs` e `lib.rs` com:
- Correção total do `Send`.
- Resurreição automática do browser.
- Retry controlado por erro de conexão.
- Paginação para 30–50 resultados.
- Logs melhorados e validação manual automática.
## ❓ Aguardando Aprovação
- [ ] Posso prosseguir com as mudanças propostas?
- [ ] Preferências de `max_concurrent_tabs` e `limit` padrão (sugestão: 5 e 40)?
- [ ] Deseja manter `force_kill_browser` como utilitário avançado, com alerta na UI para reiniciar o scraper?
+66 -21
View File
@@ -1,36 +1,81 @@
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). <div align="center">
## Getting Started <img src="assets/readme/hero.svg" width="100%" alt="OllaHub: desktop chat for your local Ollama models, with web search and MCP tools">
First, run the development server: ![Version 0.1.0](https://img.shields.io/badge/version-0.1.0-555?style=flat-square)
![Tauri 2](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square&logo=tauri&logoColor=white)
![Next.js 16](https://img.shields.io/badge/Next.js-16.0.3-000000?style=flat-square&logo=nextdotjs&logoColor=white)
![React 19](https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=black)
![Platforms](https://img.shields.io/badge/platforms-Windows_%7C_macOS_%7C_Linux-1447e6?style=flat-square)
[Quick start](#quick-start) · [Docs](docs/) · [Issues](https://git.evandro.dev.br/evandro/OllaHub/issues)
</div>
## What it is
OllaHub is a desktop chat app for the models you run with [Ollama](https://ollama.com). On first run it scans your hardware, recommends a model and downloads it. After that it chats with streaming, keeps history in a local SQLite database, and can add context from web search, MCP tools and scheduled tasks. The UI is Next.js inside a Tauri 2 window, and a Rust backend does the work.
OllaHub is a client, not a model runtime. It talks to the Ollama API (default `http://localhost:11434`, configurable in Settings), tries to start Ollama when it is installed but stopped, and can download the Ollama installer when it is missing.
## Why use it
- **Local by default.** Prompts go to your own Ollama, and chats live in `ollahub.db` in the app data directory, with FTS5 full-text search. Besides downloads (models, the embedding model, updates), web search is what reaches the internet.
- **Web answers ranked on your machine.** Headless Chrome scrapes Google, Bing, Yahoo, DuckDuckGo and Startpage (order configurable), then local embeddings (`all-MiniLM-L6-v2` on ONNX Runtime) rank and prune the pages, with a BM25-like fallback when the embedding model is unavailable. A deep-research mode runs planning → searching → aggregating → validating → formulating.
- **More than a chat box.** MCP servers run as child processes configured in `mcp_config.json`, their tools can be called from the chat, and cron tasks (search and summarize, notification, custom prompt) keep running in the background.
**When not to use it:** if you want a finished, tested product (version `0.1.0`, no test suite), a model runtime of its own, or a server you can run without a desktop.
## How it works
- **Frontend** (`app/`, `components/`, `hooks/`, `lib/`, `store/`): Next.js 16 with `output: 'export'`, React 19, Tailwind 4, Radix UI and Zustand. It calls the backend through Tauri `invoke` and events.
- **Backend** (`src-tauri/src/`): `lib.rs` holds the commands for chat (`chat_stream`), models, the Ollama lifecycle, sessions, MCP, search, tasks, export and the installer. `db.rs` handles SQLite, `web_scraper.rs` the search engines, `embeddings.rs` the ranking, and `scheduler.rs` the cron tasks stored in `tasks.json`.
- **Models:** list, delete and pull with progress, install a local `.gguf` file, and get a recommendation for your hardware.
- **System monitor:** CPU and RAM stats plus GPU VRAM, usage and temperature. GPU detection uses `wmic` and `nvidia-smi` on Windows and `lspci` on Linux.
- **Updates:** the Tauri updater checks automatically. Its endpoint in `src-tauri/tauri.conf.json` points to a GitHub Releases `latest.json`.
More notes: [`docs/updater-setup.md`](docs/updater-setup.md) (signing keys and updater) and [`docs/style-guide.md`](docs/style-guide.md) (UI spacing, radii, accessibility).
## Quick start
No release is published on this repository yet, so build it locally. You need Node.js 20, pnpm 8, a stable Rust toolchain and the [Tauri 2 prerequisites](https://tauri.app/start/prerequisites/) (the versions come from the CI workflow). On Ubuntu the workflow also installs `libwebkit2gtk-4.1-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf`. Ollama is needed at runtime.
```bash ```bash
npm run dev git clone https://git.evandro.dev.br/evandro/OllaHub.git
# or cd OllaHub
yarn dev pnpm install
# or
pnpm dev pnpm tauri dev # starts `pnpm dev` (Next.js on http://localhost:3000) inside the Tauri window
# or pnpm tauri build # runs `pnpm generate-changelog && pnpm build`, then bundles the installer
bun dev pnpm lint # ESLint
pnpm bump-version # patch | minor | major, optional --commit
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. The workflow [`.github/workflows/release.yml`](.github/workflows/release.yml) builds `msi` (Windows), `dmg` (macOS, Apple Silicon and Intel) and `AppImage` (Linux) on pushes to `main`, `master` and `develop`, and publishes release `v<version>` on pushes to `main` or `master`.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. > [!WARNING]
> **Two things the app does on its own.**
> 1. Opening the chat page pulls the small helper model **`qwen2.5:0.5b`** if it is not installed yet, used for automatic chat titles. Expect an extra download on first use.
> 2. **Closing the window only hides it.** The close request is intercepted (`prevent_close`) so scheduled tasks keep running, but there is no tray icon to bring the window back yet; the tray step is still pending in [`IMPLEMENTATION_GUIDE.md`](IMPLEMENTATION_GUIDE.md).
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. ## Project status
## Learn More Early: version `0.1.0`, 47 commits between 2025-10-27 and 2025-11-27, no automated tests (`package.json` defines `dev`, `build`, `start`, `lint`, `generate-changelog` and `bump-version`). Known limitations:
To learn more about Next.js, take a look at the following resources: - **No tray icon yet:** closing the window hides it (see the warning above).
- **"Start with the system" does nothing yet:** the setting only toggles a stored flag; the code has a `TODO` until `tauri-plugin-autostart` is configured.
- **RAG inside `chat_stream` is not wired:** the `enable_rag` branch is commented out as `TODO`; the frontend assembles web context before the request.
- **Embeddings outside Windows:** the ONNX Runtime library is downloaded automatically only on Windows. On macOS and Linux, provide it in the app data folder or on the system path, otherwise ranking falls back to BM25-like scoring.
- **Unused leftovers:** `src-tauri/scripts/scraper.py` is not called by the Rust backend, and the 188 model files in `data/models/` are not referenced by the app code.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ## License
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! No published license: all rights reserved.
## Deploy on Vercel Built with [Tauri](https://tauri.app), [Next.js](https://nextjs.org), [Radix UI](https://www.radix-ui.com), [Tailwind CSS](https://tailwindcss.com), [Zustand](https://zustand-demo.pmnd.rs), [Lucide](https://lucide.dev), [headless_chrome](https://github.com/rust-headless-chrome/rust-headless-chrome), [ONNX Runtime](https://onnxruntime.ai) and the [all-MiniLM-L6-v2](https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2) model.
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. <div align="center">
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. <sub>Made by <a href="https://evandro.dev.br">Evandro Fonseca Junior</a> · <a href="https://git.evandro.dev.br/evandro">git.evandro.dev.br</a></sub>
</div>
+237 -5
View File
@@ -48,11 +48,14 @@ export default function ChatPage() {
currentSessionId, currentSessionId,
setCurrentSessionId, setCurrentSessionId,
loadSessionHistory, loadSessionHistory,
loadMoreMessages,
paginationState,
saveSession, saveSession,
deleteSession, deleteSession,
isGeneratingTitle, isGeneratingTitle,
searchSessions, searchSessions,
isSearching isSearching,
searchQuery
} = useChatStorage(); } = useChatStorage();
const { isDownloading, progress } = useAutoLabelingModel(); const { isDownloading, progress } = useAutoLabelingModel();
@@ -121,6 +124,7 @@ export default function ChatPage() {
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedMessagesRef = useRef<number>(0); const lastSavedMessagesRef = useRef<number>(0);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showContextDebug, setShowContextDebug] = useState(false); const [showContextDebug, setShowContextDebug] = useState(false);
const [showProcessDebug, setShowProcessDebug] = useState(false); const [showProcessDebug, setShowProcessDebug] = useState(false);
const [lastWebContext, setLastWebContext] = useState(''); const [lastWebContext, setLastWebContext] = useState('');
@@ -128,6 +132,14 @@ export default function ChatPage() {
const [showDownloadDialog, setShowDownloadDialog] = useState(false); const [showDownloadDialog, setShowDownloadDialog] = useState(false);
const [logsCopied, setLogsCopied] = useState(false); const [logsCopied, setLogsCopied] = useState(false);
const [lastUserQuery, setLastUserQuery] = useState(''); const [lastUserQuery, setLastUserQuery] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
// Resetar índice quando busca é limpa ou sessão muda
useEffect(() => {
if (!searchQuery || searchQuery.trim().length < 2) {
setSearchMatchIndex(0);
}
}, [searchQuery, currentSessionId]);
// Auto-select first model // Auto-select first model
useEffect(() => { useEffect(() => {
@@ -246,6 +258,34 @@ export default function ChatPage() {
} }
}, [settings.queryPreprocessing, webSearch.isEnabled, preprocess]); }, [settings.queryPreprocessing, webSearch.isEnabled, preprocess]);
// ========== EXECUÇÃO ESPECULATIVA DE BUSCA ==========
// Heurística rápida para detectar se a query provavelmente precisa de busca web
const mightNeedWebSearch = useCallback((text: string): boolean => {
if (!webSearch.isEnabled) return false;
const lowerText = text.toLowerCase();
// Padrões que indicam alta probabilidade de precisar de busca
const questionPatterns = /\b(o que|como|quando|onde|quem|porque|por que|qual|quais|quanto|quantos|me explique|me diga|existe|há)\b/i;
const recentPatterns = /\b(hoje|ontem|esta semana|este mês|este ano|atualmente|recentemente|últimas notícias|novo|novos|última|últimas|2024|2025)\b/i;
const searchPatterns = /\?$|buscar|pesquisar|encontrar|procurar|descobrir/i;
// Se contém padrões de pergunta ou busca, provavelmente precisa de pesquisa
if (questionPatterns.test(lowerText) || recentPatterns.test(lowerText) || searchPatterns.test(lowerText)) {
chatLog.debug('[SpeculativeSearch] Query might need web search (pattern match)');
return true;
}
// Se tem mais de 5 palavras e termina com ?, provavelmente é uma pergunta
const wordCount = text.trim().split(/\s+/).length;
if (wordCount > 5 && text.trim().endsWith('?')) {
chatLog.debug('[SpeculativeSearch] Query might need web search (question detected)');
return true;
}
return false;
}, [webSearch.isEnabled]);
const handleSend = async (content: string) => { const handleSend = async (content: string) => {
if (!selectedModel) return; if (!selectedModel) return;
@@ -262,6 +302,22 @@ export default function ChatPage() {
// Limpar erros anteriores // Limpar erros anteriores
setError(null); setError(null);
// ========== EXECUÇÃO ESPECULATIVA ==========
// Iniciar busca web especulativamente se a query parecer precisar
// Isso reduz latência sobrepondo busca com pré-processamento
let speculativeSearchPromise: Promise<ScrapedContent[]> | null = null;
const shouldSpeculate = mightNeedWebSearch(content);
if (shouldSpeculate) {
chatLog.info('[SpeculativeSearch] Starting speculative web search in parallel with preprocessing');
// Iniciar busca sem await - será usada depois se confirmada
speculativeSearchPromise = webSearch.smartSearchRag(content, settings.webSearch?.maxResults || 5)
.catch(err => {
chatLog.debug(`[SpeculativeSearch] Speculative search failed (will use normal flow): ${err}`);
return [] as ScrapedContent[];
});
}
// ========== PRÉ-PROCESSAMENTO ========== // ========== PRÉ-PROCESSAMENTO ==========
let preprocessed: PreprocessedQuery; let preprocessed: PreprocessedQuery;
let finalContent: string; let finalContent: string;
@@ -330,7 +386,8 @@ export default function ChatPage() {
// Função auxiliar para executar pesquisa web // Função auxiliar para executar pesquisa web
const executeWebResearch = async ( const executeWebResearch = async (
content: string, content: string,
preprocessed: PreprocessedQuery preprocessed: PreprocessedQuery,
speculativeResults?: ScrapedContent[] | null
): Promise<{ ): Promise<{
knowledgeBaseContext: string; knowledgeBaseContext: string;
scrapedSources: ScrapedContent[]; scrapedSources: ScrapedContent[];
@@ -344,6 +401,12 @@ export default function ChatPage() {
if (webSearch.isEnabled && preprocessed.shouldSearch) { if (webSearch.isEnabled && preprocessed.shouldSearch) {
chatLog.info('\n--- STARTING DEEP RESEARCH PIPELINE ---'); chatLog.info('\n--- STARTING DEEP RESEARCH PIPELINE ---');
// Se temos resultados especulativos, usá-los como seed inicial
if (speculativeResults && speculativeResults.length > 0) {
chatLog.info(`[SpeculativeSearch] Using ${speculativeResults.length} speculative results as seed`);
deepResearch.addToKnowledgeBase('speculative_seed', speculativeResults);
}
// Adicionar mensagem de processo: Pesquisa Web // Adicionar mensagem de processo: Pesquisa Web
addThinkingMessage('web-research', 'Pesquisando na Web...', 'running'); addThinkingMessage('web-research', 'Pesquisando na Web...', 'running');
const webResearchStart = Date.now(); const webResearchStart = Date.now();
@@ -605,10 +668,25 @@ export default function ChatPage() {
// ========== LÓGICA DE DEEP RESEARCH (Knowledge Base Aggregation) ========== // ========== LÓGICA DE DEEP RESEARCH (Knowledge Base Aggregation) ==========
// Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas // Removido: setThinkingStep e setProcessSteps - agora usando mensagens thinking agrupadas
// Executar pesquisa web // Aguardar resultado da busca especulativa se ela foi iniciada e shouldSearch é true
let speculativeResults: ScrapedContent[] | null = null;
if (speculativeSearchPromise && preprocessed.shouldSearch) {
chatLog.info('[SpeculativeSearch] Waiting for speculative search results...');
try {
speculativeResults = await speculativeSearchPromise;
chatLog.info(`[SpeculativeSearch] Got ${speculativeResults.length} speculative results`);
} catch (err) {
chatLog.debug(`[SpeculativeSearch] Failed to get speculative results: ${err}`);
}
} else if (speculativeSearchPromise && !preprocessed.shouldSearch) {
chatLog.info('[SpeculativeSearch] Discarding speculative search (preprocessing determined search not needed)');
}
// Executar pesquisa web (usando resultados especulativos como seed se disponíveis)
const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch( const { knowledgeBaseContext, scrapedSources, validationReport } = await executeWebResearch(
content, content,
preprocessed preprocessed,
speculativeResults
); );
// Passo 5: Formular resposta // Passo 5: Formular resposta
@@ -803,8 +881,94 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
setMessages(history); setMessages(history);
setCurrentSessionId(id); setCurrentSessionId(id);
lastSavedMessagesRef.current = history.length; lastSavedMessagesRef.current = history.length;
// Se há busca ativa, encontrar matches e resetar índice
if (searchQuery && searchQuery.trim().length >= 2) {
setSearchMatchIndex(0);
// Os matches serão encontrados quando as mensagens renderizarem
}
}; };
// Handler para scroll infinito reverso (carregar mensagens mais antigas)
const handleScroll = useCallback(async () => {
const container = scrollContainerRef.current;
if (!container || !currentSessionId || paginationState.isLoadingMore) return;
// Verificar se o usuário está próximo do topo (threshold de 100px)
const isNearTop = container.scrollTop < 100;
if (isNearTop && paginationState.hasMore) {
// Salvar posição atual do scroll e altura do conteúdo
const scrollHeightBefore = container.scrollHeight;
// Carregar mais mensagens
const olderMessages = await loadMoreMessages(currentSessionId);
if (olderMessages.length > 0) {
// Prepend as mensagens antigas
setMessages(prev => [...olderMessages, ...prev]);
// Restaurar posição do scroll para manter o contexto visual
// (compensar o novo conteúdo adicionado no topo)
requestAnimationFrame(() => {
if (container) {
const scrollHeightAfter = container.scrollHeight;
const heightDiff = scrollHeightAfter - scrollHeightBefore;
container.scrollTop = container.scrollTop + heightDiff;
}
});
}
}
}, [currentSessionId, paginationState.hasMore, paginationState.isLoadingMore, loadMoreMessages, setMessages]);
// Função para navegar entre matches
const handleNavigateMatch = useCallback((sessionId: string, direction: 'prev' | 'next') => {
if (sessionId !== currentSessionId || !searchQuery || searchQuery.trim().length < 2) {
return;
}
// Encontrar todos os matches nas mensagens
const query = searchQuery.trim();
const matches: Array<{ messageIndex: number; matchIndex: number }> = [];
messages.forEach((msg, msgIdx) => {
if (msg.role === 'user' || msg.role === 'assistant') {
const content = msg.content;
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
let match;
let matchIdx = 0;
while ((match = regex.exec(content)) !== null) {
matches.push({ messageIndex: msgIdx, matchIndex: matchIdx });
matchIdx++;
}
}
});
if (matches.length === 0) return;
// Navegar
setSearchMatchIndex(prev => {
const newIndex = direction === 'next'
? (prev + 1) % matches.length
: (prev - 1 + matches.length) % matches.length;
// Scroll para o match após renderização
setTimeout(() => {
const messageElement = document.querySelector(`[data-message-index="${matches[newIndex].messageIndex}"]`);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Destacar o match atual visualmente
const marks = messageElement.querySelectorAll('mark');
if (marks.length > matches[newIndex].matchIndex) {
marks[matches[newIndex].matchIndex]?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
}
}
}, 150);
return newIndex;
});
}, [currentSessionId, searchQuery, messages]);
const handleNewChat = () => { const handleNewChat = () => {
stop(); stop();
clearChat(); clearChat();
@@ -947,6 +1111,8 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
onNewChat={handleNewChat} onNewChat={handleNewChat}
onSearch={searchSessions} onSearch={searchSessions}
isSearching={isSearching} isSearching={isSearching}
searchQuery={searchQuery}
onNavigateMatch={handleNavigateMatch}
/> />
</ResizablePanel> </ResizablePanel>
@@ -1050,8 +1216,29 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
{/* Messages Area with Floating Input Layout */} {/* Messages Area with Floating Input Layout */}
<div className="flex-1 relative h-full overflow-hidden flex flex-col"> <div className="flex-1 relative h-full overflow-hidden flex flex-col">
{/* Scrollable Content Area */} {/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0"> <div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto scroll-smooth w-full min-h-0"
onScroll={handleScroll}
>
<div className="max-w-3xl mx-auto w-full px-3 sm:px-4 md:px-6 pt-4 sm:pt-6 pb-4 transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}> <div className="max-w-3xl mx-auto w-full px-3 sm:px-4 md:px-6 pt-4 sm:pt-6 pb-4 transition-all duration-300" style={{ paddingLeft: 'clamp(12px, 4vw, 24px)', paddingRight: 'clamp(12px, 4vw, 24px)' }}>
{/* Indicador de carregamento de mensagens antigas */}
{paginationState.isLoadingMore && (
<div className="flex items-center justify-center py-4 mb-4">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground mr-2" />
<span className="text-sm text-muted-foreground">Carregando mensagens anteriores...</span>
</div>
)}
{/* Indicador de que há mais mensagens para carregar */}
{paginationState.hasMore && !paginationState.isLoadingMore && messages.length > 0 && (
<div className="flex items-center justify-center py-2 mb-4">
<span className="text-xs text-muted-foreground/60">
Role para cima para ver mensagens anteriores ({paginationState.totalCount - messages.length} restantes)
</span>
</div>
)}
{messages.length === 0 ? ( {messages.length === 0 ? (
<div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6"> <div className="min-h-[50vh] flex flex-col items-center justify-center text-muted-foreground space-y-6">
<div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm"> <div className="p-6 rounded-2xl bg-muted/30 ring-1 ring-border/50 shadow-sm">
@@ -1086,6 +1273,13 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
'type' in msg.metadata && 'type' in msg.metadata &&
msg.metadata.type === 'thinking'; msg.metadata.type === 'thinking';
// Pular mensagens de sistema que não são thinking (system prompt)
// Essas mensagens nunca devem aparecer no chat
const isSystemPrompt = msg.role === 'system' && !isThinkingMessage;
if (isSystemPrompt) {
continue;
}
if (isThinkingMessage) { if (isThinkingMessage) {
const thinkingMeta = msg.metadata as ThinkingMessageMetadata; const thinkingMeta = msg.metadata as ThinkingMessageMetadata;
currentThinkingGroup.push(thinkingMeta); currentThinkingGroup.push(thinkingMeta);
@@ -1132,6 +1326,25 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
}); });
} }
// Calcular posições de todos os matches uma vez
const allMatches: Array<{ messageIndex: number; matchIndex: number }> = [];
if (searchQuery && searchQuery.trim().length >= 2) {
const query = searchQuery.trim();
const regex = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
messages.forEach((msg, msgIdx) => {
if (msg.role === 'user' || msg.role === 'assistant') {
const content = msg.content;
let match;
let matchIdx = 0;
while ((match = regex.exec(content)) !== null) {
allMatches.push({ messageIndex: msgIdx, matchIndex: matchIdx });
matchIdx++;
}
}
});
}
return groupedMessages.map((group, idx) => { return groupedMessages.map((group, idx) => {
if (group.type === 'thinking-group' && group.thinkingSteps) { if (group.type === 'thinking-group' && group.thinkingSteps) {
return ( return (
@@ -1143,11 +1356,30 @@ Ao responder sobre fatos atuais ou notícias, inicie mencionando explicitamente
</div> </div>
); );
} else if (group.type === 'regular' && group.message) { } else if (group.type === 'regular' && group.message) {
// Encontrar índice do match atual para esta mensagem
let messageMatchIndex: number | undefined = undefined;
if (searchQuery && searchQuery.trim().length >= 2 && allMatches.length > 0 && group.index !== undefined) {
// Encontrar matches desta mensagem
const messageMatches = allMatches.filter(m => m.messageIndex === group.index);
if (messageMatches.length > 0) {
// Verificar se o match atual está nesta mensagem
const currentMatch = allMatches[searchMatchIndex];
if (currentMatch && currentMatch.messageIndex === group.index) {
messageMatchIndex = currentMatch.matchIndex;
}
}
}
return ( return (
<div key={group.index || idx} className="animate-in fade-in slide-in-from-bottom-4 duration-500"> <div key={group.index || idx} className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<ChatMessage <ChatMessage
message={group.message} message={group.message}
isStreaming={group.isStreaming} isStreaming={group.isStreaming}
highlightTerm={searchQuery && searchQuery.trim().length >= 2 ? searchQuery.trim() : undefined}
highlightIndex={messageMatchIndex}
messageIndex={group.index}
/> />
</div> </div>
); );
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { AppUpdaterProvider } from "@/components/app-updater-provider";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -35,6 +36,7 @@ export default function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
{children} {children}
<AppUpdaterProvider />
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>
+90 -1
View File
@@ -24,11 +24,13 @@ import { useSettingsStore } from '@/store/settings-store';
import { DomainTagsInput } from '@/components/settings/domain-tags-input'; import { DomainTagsInput } from '@/components/settings/domain-tags-input';
import { HardwareDashboard } from '@/components/settings/HardwareDashboard'; import { HardwareDashboard } from '@/components/settings/HardwareDashboard';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign, Edit, RotateCcw, Terminal, Power, ArrowLeft } from 'lucide-react'; import { CheckCircle2, XCircle, Loader2, Download, Trash2, Copy, ExternalLink, Plus, X, BookOpen, GraduationCap, Newspaper, Code, DollarSign, Edit, RotateCcw, Terminal, Power, ArrowLeft, Sparkles, RefreshCw } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { SourcesConfig, SourceCategory } from '@/lib/types'; import { SourcesConfig, SourceCategory } from '@/lib/types';
import { TitleBar } from '@/components/titlebar'; import { TitleBar } from '@/components/titlebar';
import { useAppUpdater } from '@/hooks/use-app-updater';
import { getVersion } from '@tauri-apps/api/app';
interface Model { interface Model {
name: string; name: string;
@@ -52,12 +54,20 @@ export default function SettingsPage() {
const [isSavingSources, setIsSavingSources] = useState(false); const [isSavingSources, setIsSavingSources] = useState(false);
const [recentLogs, setRecentLogs] = useState<string[]>([]); const [recentLogs, setRecentLogs] = useState<string[]>([]);
const [isLoadingLogs, setIsLoadingLogs] = useState(false); const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [appVersion, setAppVersion] = useState<string>('');
const updater = useAppUpdater();
// Load models, storage path, and sources config on mount // Load models, storage path, and sources config on mount
useEffect(() => { useEffect(() => {
loadModels(); loadModels();
loadStoragePath(); loadStoragePath();
settings.fetchSources(); settings.fetchSources();
// Load app version
getVersion()
.then(setAppVersion)
.catch(() => setAppVersion('0.1.0'));
}, [settings.ollamaUrl]); }, [settings.ollamaUrl]);
// Load logs periodically // Load logs periodically
@@ -1095,6 +1105,85 @@ export default function SettingsPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Atualizações</CardTitle>
<CardDescription>
Configure verificação automática de atualizações
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="auto-check-updates">Verificar Atualizações Automaticamente</Label>
<p className="text-xs text-muted-foreground">
O app verificará novas versões periodicamente
</p>
</div>
<Switch
id="auto-check-updates"
checked={settings.autoCheckUpdates}
onCheckedChange={settings.setAutoCheckUpdates}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Versão Atual</span>
<span className="font-mono font-medium">
{appVersion || 'Carregando...'}
</span>
</div>
{updater.updateAvailable && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Nova Versão Disponível</span>
<span className="font-mono font-medium text-primary">
{updater.updateInfo?.version}
</span>
</div>
)}
</div>
<Button
onClick={async () => {
await updater.checkUpdate();
if (updater.updateAvailable) {
toast({
title: 'Atualização disponível',
description: `Versão ${updater.updateInfo?.version} está disponível para download.`,
});
} else {
toast({
title: 'Você está atualizado',
description: 'Não há novas atualizações disponíveis.',
});
}
}}
variant="outline"
className="w-full"
disabled={updater.isChecking}
>
{updater.isChecking ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verificando...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Verificar Atualizações
</>
)}
</Button>
{updater.error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{updater.error}</p>
</div>
)}
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
+59
View File
@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="420" viewBox="0 0 1200 420" role="img" aria-labelledby="title desc">
<title id="title">OllaHub</title>
<desc id="desc">OllaHub: a desktop window with a chat sidebar, a local Ollama model download in progress, an answer backed by numbered web sources, and Web and MCP toggles in the message box.</desc>
<rect width="1200" height="420" rx="24" fill="#09090B"/>
<g id="title-block" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<text x="64" y="170" font-size="64" font-weight="700" fill="#FAFAFA">OllaHub</text>
<text x="64" y="226" font-size="28" fill="#FAFAFA">Desktop chat for your local Ollama</text>
<text x="64" y="264" font-size="28" fill="#FAFAFA">models, with web search and MCP.</text>
<text x="64" y="322" font-size="20" fill="#9F9FA9" font-family="ui-monospace, 'SF Mono', Consolas, monospace">Tauri 2 · Rust · Next.js 16 · React 19</text>
</g>
<g id="window" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">
<rect x="608" y="52" width="528" height="316" rx="14" fill="#09090B" stroke="#27272A" stroke-width="2"/>
<path d="M622 52 h500 a14 14 0 0 1 14 14 v26 h-528 v-26 a14 14 0 0 1 14 -14 z" fill="#18181B"/>
<text x="628" y="79" font-size="18" fill="#9F9FA9">OllaHub</text>
<g fill="none" stroke="#9F9FA9" stroke-width="2" stroke-linecap="round">
<path d="M1052 72 h14"/>
<rect x="1078" y="65" width="14" height="14" rx="2"/>
<path d="M1106 65 l14 14 M1120 65 l-14 14"/>
</g>
<path d="M608 92 h528" stroke="#27272A" stroke-width="2"/>
<path d="M608 92 h144 v276 h-130 a14 14 0 0 1 -14 -14 z" fill="#18181B"/>
<rect x="622" y="108" width="116" height="34" rx="8" fill="#1447E6"/>
<text x="680" y="131" font-size="18" fill="#FAFAFA" text-anchor="middle">New chat</text>
<rect x="622" y="166" width="100" height="10" rx="5" fill="#27272A"/>
<rect x="622" y="192" width="84" height="10" rx="5" fill="#27272A"/>
<rect x="622" y="218" width="94" height="10" rx="5" fill="#27272A"/>
<rect x="622" y="244" width="72" height="10" rx="5" fill="#27272A"/>
<text x="772" y="128" font-size="18" fill="#FAFAFA" font-family="ui-monospace, 'SF Mono', Consolas, monospace">qwen2.5:0.5b</text>
<rect x="772" y="140" width="344" height="10" rx="5" fill="#27272A"/>
<rect x="772" y="140" width="220" height="10" rx="5" fill="#1447E6"/>
<rect x="772" y="170" width="344" height="116" rx="12" fill="#201E25"/>
<rect x="790" y="190" width="290" height="10" rx="5" fill="#9F9FA9"/>
<rect x="790" y="212" width="300" height="10" rx="5" fill="#9F9FA9"/>
<rect x="790" y="234" width="180" height="10" rx="5" fill="#9F9FA9"/>
<text x="790" y="272" font-size="18" fill="#9F9FA9">Sources</text>
<g font-size="18" fill="#FAFAFA" text-anchor="middle">
<rect x="866" y="254" width="26" height="26" rx="13" fill="#27272A"/>
<text x="879" y="273">1</text>
<rect x="898" y="254" width="26" height="26" rx="13" fill="#27272A"/>
<text x="911" y="273">2</text>
<rect x="930" y="254" width="26" height="26" rx="13" fill="#27272A"/>
<text x="943" y="273">3</text>
</g>
<rect x="772" y="302" width="344" height="40" rx="20" fill="#18181B" stroke="#27272A" stroke-width="2"/>
<rect x="782" y="309" width="60" height="26" rx="13" fill="#27272A"/>
<text x="812" y="328" font-size="18" fill="#FAFAFA" text-anchor="middle">Web</text>
<rect x="850" y="309" width="62" height="26" rx="13" fill="#27272A"/>
<text x="881" y="328" font-size="18" fill="#FAFAFA" text-anchor="middle">MCP</text>
<circle cx="1096" cy="322" r="13" fill="#1447E6"/>
<path d="M1096 328 v-12 M1090 321 l6 -6 l6 6" fill="none" stroke="#FAFAFA" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+40
View File
@@ -0,0 +1,40 @@
'use client';
import { useEffect, useState } from 'react';
import { UpdateDialog } from '@/components/modals/update-dialog';
import { useAppUpdater } from '@/hooks/use-app-updater';
import { useSettingsStore } from '@/store/settings-store';
export function AppUpdaterProvider() {
const { updateAvailable, checkUpdate } = useAppUpdater();
const [isOpen, setIsOpen] = useState(false);
const [hasChecked, setHasChecked] = useState(false);
const autoCheckEnabled = useSettingsStore((state) => state.autoCheckUpdates ?? true);
useEffect(() => {
if (!autoCheckEnabled || hasChecked) return;
// Verificar após um delay para não interferir no carregamento inicial
const timer = setTimeout(() => {
checkUpdate().then(() => {
setHasChecked(true);
});
}, 5000); // 5 segundos após o app carregar
return () => clearTimeout(timer);
}, [autoCheckEnabled, hasChecked, checkUpdate]);
useEffect(() => {
if (updateAvailable && !isOpen) {
setIsOpen(true);
}
}, [updateAvailable, isOpen]);
return (
<UpdateDialog
open={isOpen}
onOpenChange={setIsOpen}
/>
);
}
+122 -14
View File
@@ -1,6 +1,6 @@
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Message } from "@/hooks/use-chat"; import { Message } from "@/hooks/use-chat";
import { Bot, User } from "lucide-react"; import { Bot, User } from "lucide-react";
@@ -13,9 +13,19 @@ import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
interface ChatMessageProps { interface ChatMessageProps {
message: Message; message: Message;
isStreaming?: boolean; isStreaming?: boolean;
highlightTerm?: string;
highlightIndex?: number;
messageIndex?: number;
} }
export function ChatMessage({ message, isStreaming = false }: ChatMessageProps) { /**
* Componente de mensagem do chat otimizado com React.memo
*
* Durante streaming, usa renderização simplificada (texto puro) para evitar
* recálculo custoso da árvore DOM e syntax highlight a cada token.
* Markdown completo é aplicado apenas quando a mensagem está finalizada.
*/
function ChatMessageComponent({ message, isStreaming = false, highlightTerm, highlightIndex = 0, messageIndex }: ChatMessageProps) {
// Verificar se é mensagem de processo de pensamento // Verificar se é mensagem de processo de pensamento
const isThinkingMessage = message.metadata && const isThinkingMessage = message.metadata &&
typeof message.metadata === 'object' && typeof message.metadata === 'object' &&
@@ -52,6 +62,57 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
return content || message.content; return content || message.content;
}, [message.content]); }, [message.content]);
// Processar highlight separadamente (após limpeza de conteúdo)
// Desabilitar durante streaming para performance
const highlightedContent = useMemo(() => {
// Não processar highlight durante streaming
if (isStreaming) return null;
if (!highlightTerm || highlightTerm.trim().length < 2 || (message.role !== 'user' && message.role !== 'assistant')) {
return null; // Não há highlight
}
const term = highlightTerm.trim();
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedTerm})`, 'gi');
const content = processedContent;
// Encontrar todos os matches
const matches: Array<{ start: number; end: number; text: string }> = [];
let match;
while ((match = regex.exec(content)) !== null) {
matches.push({
start: match.index,
end: match.index + match[0].length,
text: match[0]
});
}
if (matches.length === 0) return null;
// Construir conteúdo com highlights
let highlighted = '';
let lastIndex = 0;
matches.forEach((match, idx) => {
// Adicionar texto antes do match
highlighted += content.substring(lastIndex, match.start);
// Adicionar match com highlight (laranja para atual, amarelo para outros)
const isCurrent = highlightIndex !== undefined && idx === highlightIndex;
const className = isCurrent
? 'bg-orange-400/60 dark:bg-orange-500/60 px-0.5 rounded'
: 'bg-yellow-200/50 dark:bg-yellow-800/50 px-0.5 rounded';
highlighted += `<mark class="${className}">${match.text}</mark>`;
lastIndex = match.end;
});
// Adicionar texto restante
highlighted += content.substring(lastIndex);
return highlighted;
}, [processedContent, highlightTerm, highlightIndex, message.role, isStreaming]);
return ( return (
<MessageBackground <MessageBackground
role={isUser ? 'user' : 'assistant'} role={isUser ? 'user' : 'assistant'}
@@ -75,11 +136,14 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden min-w-0 pt-1"> <div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
{isUser ? ( {isUser ? (
// Estilo específico para mensagem do usuário (mais destaque, fonte maior) // Estilo específico para mensagem do usuário (mais destaque, fonte maior)
<div className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"> <div
{processedContent} className="text-lg sm:text-xl font-medium tracking-tight text-foreground/90 leading-relaxed break-words whitespace-pre-wrap"
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
>
{!highlightedContent && processedContent}
</div> </div>
) : ( ) : (
// Estilo Assistant // Estilo Assistant
@@ -88,22 +152,37 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" /> <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
<span className="text-sm font-medium">Gerando resposta...</span> <span className="text-sm font-medium">Gerando resposta...</span>
</div> </div>
) : isStreaming ? (
// Renderização LEVE durante streaming: texto simples com formatação básica
// Evita recálculo de ReactMarkdown e syntax highlight a cada token
<div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-headings:font-semibold prose-headings:tracking-tight",
"relative"
)}>
<div className="whitespace-pre-wrap font-sans">
{processedContent}
</div>
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" />
</div>
) : ( ) : (
// Renderização COMPLETA quando mensagem finalizada: ReactMarkdown com syntax highlight
<div className={cn( <div className={cn(
"prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90", "prose prose-neutral dark:prose-invert max-w-none break-words leading-7 text-base text-foreground/90",
"prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg", "prose-headings:font-semibold prose-headings:tracking-tight prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg",
"prose-p:leading-7 prose-li:leading-7", "prose-p:leading-7 prose-li:leading-7",
"prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl", "prose-pre:bg-muted/50 prose-pre:border prose-pre:border-border/50 prose-pre:rounded-xl",
isStreaming && "relative" "prose-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded"
)}> )}>
<ReactMarkdown {highlightedContent ? (
remarkPlugins={[remarkGfm]} <div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
components={markdownRenderers} ) : (
> <ReactMarkdown
{processedContent} remarkPlugins={[remarkGfm]}
</ReactMarkdown> components={markdownRenderers}
{isStreaming && !isEmpty && ( >
<span className="inline-block w-2 h-4 bg-primary ml-1 animate-pulse rounded-full align-middle" /> {processedContent}
</ReactMarkdown>
)} )}
</div> </div>
) )
@@ -123,5 +202,34 @@ export function ChatMessage({ message, isStreaming = false }: ChatMessageProps)
); );
} }
/**
* ChatMessage com React.memo para evitar re-renders desnecessários
*
* Comparação customizada:
* - Compara conteúdo da mensagem
* - Compara estado de streaming
* - Compara termo de highlight e índice
*/
export const ChatMessage = React.memo(ChatMessageComponent, (prevProps, nextProps) => {
// Se streaming mudou, sempre re-renderizar
if (prevProps.isStreaming !== nextProps.isStreaming) {
return false;
}
// Durante streaming, só re-renderizar se conteúdo mudou
if (nextProps.isStreaming) {
return prevProps.message.content === nextProps.message.content;
}
// Quando não está em streaming, comparar todas as props relevantes
return (
prevProps.message.content === nextProps.message.content &&
prevProps.message.role === nextProps.message.role &&
prevProps.highlightTerm === nextProps.highlightTerm &&
prevProps.highlightIndex === nextProps.highlightIndex &&
prevProps.messageIndex === nextProps.messageIndex
);
});
// Adicionar displayName para debugging
ChatMessage.displayName = 'ChatMessage';
+58 -15
View File
@@ -2,7 +2,7 @@ import { SessionSummary } from "@/hooks/use-chat-storage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Trash2, Plus, MessageSquare, Search, X, Loader2 } from "lucide-react"; import { Trash2, Plus, MessageSquare, Search, X, Loader2, ChevronUp, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
@@ -14,6 +14,8 @@ interface SidebarListProps {
onNewChat: () => void; onNewChat: () => void;
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
isSearching?: boolean; isSearching?: boolean;
searchQuery?: string;
onNavigateMatch?: (sessionId: string, direction: 'prev' | 'next') => void;
} }
function formatDate(dateString: string) { function formatDate(dateString: string) {
@@ -58,9 +60,11 @@ export function SidebarList({
onDeleteSession, onDeleteSession,
onNewChat, onNewChat,
onSearch, onSearch,
isSearching = false isSearching = false,
searchQuery = '',
onNavigateMatch
}: SidebarListProps) { }: SidebarListProps) {
const [searchQuery, setSearchQuery] = useState(''); const [localSearchInput, setLocalSearchInput] = useState('');
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null); const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 300; const DEBOUNCE_MS = 300;
@@ -71,7 +75,7 @@ export function SidebarList({
} }
// Se query vazia, limpar busca imediatamente // Se query vazia, limpar busca imediatamente
if (!searchQuery.trim()) { if (!localSearchInput.trim()) {
if (onSearch) { if (onSearch) {
onSearch(''); onSearch('');
} }
@@ -80,8 +84,8 @@ export function SidebarList({
// Debounce: aguardar antes de buscar // Debounce: aguardar antes de buscar
searchTimeoutRef.current = setTimeout(() => { searchTimeoutRef.current = setTimeout(() => {
if (onSearch && searchQuery.trim().length >= 2) { if (onSearch && localSearchInput.trim().length >= 2) {
onSearch(searchQuery.trim()); onSearch(localSearchInput.trim());
} }
}, DEBOUNCE_MS); }, DEBOUNCE_MS);
@@ -91,10 +95,10 @@ export function SidebarList({
clearTimeout(searchTimeoutRef.current); clearTimeout(searchTimeoutRef.current);
} }
}; };
}, [searchQuery, onSearch]); }, [localSearchInput, onSearch]);
const handleClearSearch = () => { const handleClearSearch = () => {
setSearchQuery(''); setLocalSearchInput('');
if (onSearch) { if (onSearch) {
onSearch(''); onSearch('');
} }
@@ -111,14 +115,14 @@ export function SidebarList({
<Input <Input
type="text" type="text"
placeholder="Buscar conversas..." placeholder="Buscar conversas..."
value={searchQuery} value={localSearchInput}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setLocalSearchInput(e.target.value)}
className="pl-8 pr-8 h-9 text-sm" className="pl-8 pr-8 h-9 text-sm"
/> />
{isSearching && ( {isSearching && (
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" /> <Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
)} )}
{searchQuery && !isSearching && ( {localSearchInput && !isSearching && (
<button <button
onClick={handleClearSearch} onClick={handleClearSearch}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors" className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
@@ -190,16 +194,55 @@ export function SidebarList({
</div> </div>
{/* Texto do card */} {/* Texto do card */}
<div className="sidebar-chat-item-text"> <div className="sidebar-chat-item-text flex-1 min-w-0">
<h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5"> <h3 className="sidebar-chat-item-title text-sm font-medium text-card-foreground leading-tight mb-0.5">
{session.title} {session.title}
</h3> </h3>
<p className="sidebar-chat-item-date text-xs text-muted-foreground"> <div className="flex items-center gap-2">
{formatDate(session.updated_at)} <p className="sidebar-chat-item-date text-xs text-muted-foreground">
</p> {formatDate(session.updated_at)}
</p>
{searchQuery && session.match_count !== undefined && session.match_count > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
{session.match_count} {session.match_count === 1 ? 'ocorrência' : 'ocorrências'}
</span>
)}
</div>
</div> </div>
{/* Navegação de matches - aparece apenas quando selecionado e há busca ativa */}
{isSelected && searchQuery && session.match_count !== undefined && session.match_count > 0 && onNavigateMatch && (
<div className="flex flex-col gap-0.5 mr-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onNavigateMatch(session.id, 'prev');
}}
aria-label="Ocorrência anterior"
>
<ChevronUp className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onNavigateMatch(session.id, 'next');
}}
aria-label="Próxima ocorrência"
>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
</div>
)}
{/* Botão de deletar - aparece no hover */} {/* Botão de deletar - aparece no hover */}
<Button <Button
variant="ghost" variant="ghost"
+153
View File
@@ -0,0 +1,153 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Download, Loader2, X, Sparkles } from 'lucide-react';
import { useAppUpdater } from '@/hooks/use-app-updater';
import { useSettingsStore } from '@/store/settings-store';
interface UpdateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UpdateDialog({ open, onOpenChange }: UpdateDialogProps) {
const {
currentVersion,
updateAvailable,
updateInfo,
isChecking,
isDownloading,
downloadProgress,
error,
installUpdate,
} = useAppUpdater();
const [dontShowAgain, setDontShowAgain] = useState(false);
const setAutoCheckUpdates = useSettingsStore((state) => state.setAutoCheckUpdates);
const handleInstall = async () => {
if (dontShowAgain) {
setAutoCheckUpdates(false);
}
await installUpdate();
};
const handleClose = () => {
if (dontShowAgain) {
setAutoCheckUpdates(false);
}
onOpenChange(false);
};
if (!updateAvailable && !isChecking) {
return null;
}
return (
<Dialog open={open && updateAvailable} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
Atualização Disponível
</DialogTitle>
<DialogDescription>
Uma nova versão do OllaHub está disponível para download.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Versão Atual</span>
<span className="font-mono font-medium">{currentVersion}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Nova Versão</span>
<span className="font-mono font-medium text-primary">
{updateInfo?.version}
</span>
</div>
</div>
{updateInfo?.body && (
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground line-clamp-3">
{updateInfo.body}
</p>
</div>
)}
{isDownloading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Baixando atualização...</span>
<span className="font-medium">{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
</div>
)}
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="dont-show-updates"
checked={dontShowAgain}
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
/>
<Label
htmlFor="dont-show-updates"
className="text-sm font-normal cursor-pointer"
>
Não verificar atualizações automaticamente
</Label>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={handleClose}
disabled={isDownloading}
>
<X className="h-4 w-4 mr-2" />
Depois
</Button>
<Button
onClick={handleInstall}
disabled={isDownloading || isChecking}
>
{isDownloading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Baixando...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Atualizar Agora
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+118
View File
@@ -0,0 +1,118 @@
# Configuração do Sistema de Atualização Automática
Este documento explica como configurar o sistema de atualização automática do OllaHub.
## Pré-requisitos
1. Repositório GitHub configurado
2. Rust e Cargo instalados
3. Tauri CLI instalado
## Passo 1: Gerar Chaves de Assinatura
Execute o script para gerar as chaves:
```bash
chmod +x scripts/generate-updater-keys.sh
./scripts/generate-updater-keys.sh
```
Ou manualmente:
```bash
mkdir -p ~/.tauri
cargo tauri signer generate -w ~/.tauri/ollahub.key
```
## Passo 2: Configurar Chave Pública
1. Copie a chave pública gerada (está em `~/.tauri/ollahub.key.pub`)
2. Edite `src-tauri/tauri.conf.json`
3. Substitua `YOUR_PUBLIC_KEY_HERE` pela chave pública
4. Atualize o endpoint com seu repositório GitHub:
```json
{
"updater": {
"active": true,
"endpoints": [
"https://api.github.com/repos/SEU_USUARIO/SEU_REPOSITORIO/releases/latest"
],
"dialog": true,
"pubkey": "SUA_CHAVE_PUBLICA_AQUI"
}
}
```
## Passo 3: Configurar GitHub Secrets
1. Vá em **Settings** > **Secrets and variables** > **Actions**
2. Clique em **New repository secret**
3. Nome: `TAURI_UPDATER_PRIVATE_KEY`
4. Valor: Cole o conteúdo completo do arquivo `~/.tauri/ollahub.key`
## Passo 4: Atualizar Workflow (se necessário)
O workflow `.github/workflows/release.yml` já está configurado para:
- Buildar para Windows, macOS e Linux
- Assinar binários com a chave privada
- Criar release automaticamente quando há push em `main`/`master`
## Como Funciona
1. **Build Automático**: Quando você faz push para `main`/`master`, o GitHub Actions:
- Builda o app para todas as plataformas
- Assina os binários
- Cria uma release no GitHub
2. **Verificação de Atualizações**: O app verifica automaticamente:
- 5 segundos após iniciar (primeira verificação)
- A cada 6 horas (verificação periódica)
- Manualmente via botão nas configurações
3. **Instalação**: Quando há atualização disponível:
- Usuário recebe notificação
- Pode escolher atualizar agora ou depois
- Download e instalação são automáticos
- App reinicia automaticamente após instalação
## Bump de Versão
Para criar uma nova versão:
```bash
# Patch (0.1.0 -> 0.1.1)
pnpm run bump-version patch --commit
# Minor (0.1.0 -> 0.2.0)
pnpm run bump-version minor --commit
# Major (0.1.0 -> 1.0.0)
pnpm run bump-version major --commit
```
Depois, faça push:
```bash
git push && git push --tags
```
O GitHub Actions criará automaticamente a release.
## Troubleshooting
### Erro: "Failed to check for updates"
- Verifique se o endpoint no `tauri.conf.json` está correto
- Verifique se há releases públicas no GitHub
- Verifique a conexão com a internet
### Erro: "Signature verification failed"
- Verifique se a chave pública no `tauri.conf.json` está correta
- Verifique se a chave privada no GitHub Secret está correta
- Regere as chaves se necessário
### Release não é criada automaticamente
- Verifique se o push foi feito para `main` ou `master`
- Verifique os logs do GitHub Actions
- Verifique se as permissões do workflow estão corretas
+142
View File
@@ -0,0 +1,142 @@
import { useState, useEffect, useCallback } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { useSettingsStore } from '@/store/settings-store';
import { check as checkForUpdate } from '@tauri-apps/plugin-updater';
export interface UpdateInfo {
version: string;
date?: string;
body?: string;
}
export interface AppUpdaterState {
currentVersion: string;
updateAvailable: boolean;
updateInfo: UpdateInfo | null;
isChecking: boolean;
isDownloading: boolean;
downloadProgress: number;
error: string | null;
checkUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
}
export function useAppUpdater(): AppUpdaterState {
const [currentVersion, setCurrentVersion] = useState<string>('');
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const autoCheckEnabled = useSettingsStore((state) => state.autoCheckUpdates ?? true);
// Obter versão atual
useEffect(() => {
const fetchVersion = async () => {
try {
const version = await getVersion();
setCurrentVersion(version);
} catch (err) {
console.error('Failed to get app version:', err);
setCurrentVersion('0.1.0');
}
};
fetchVersion();
}, []);
// Verificar atualizações
const checkUpdate = useCallback(async () => {
setIsChecking(true);
setError(null);
try {
const update = await checkForUpdate();
if (update?.available) {
setUpdateAvailable(true);
setUpdateInfo({
version: update.version,
date: update.date,
body: update.body,
});
} else {
setUpdateAvailable(false);
setUpdateInfo(null);
}
} catch (err) {
console.error('Failed to check for updates:', err);
setError(err instanceof Error ? err.message : 'Erro ao verificar atualizações');
setUpdateAvailable(false);
} finally {
setIsChecking(false);
}
}, []);
// Instalar atualização
const installUpdate = useCallback(async () => {
if (!updateAvailable) return;
setIsDownloading(true);
setError(null);
setDownloadProgress(0);
try {
const update = await checkForUpdate();
if (update?.available) {
// Escutar progresso do download
const unlisten = await update.on('download-progress', (progress: any) => {
setDownloadProgress(progress.progress || 0);
});
// Baixar e instalar atualização
await update.downloadAndInstall();
unlisten();
// Reiniciar aplicativo após instalação
await update.installAndRestart();
}
} catch (err) {
console.error('Failed to install update:', err);
setError(err instanceof Error ? err.message : 'Erro ao instalar atualização');
setIsDownloading(false);
}
}, [updateAvailable]);
// Verificação automática periódica (a cada 6 horas)
useEffect(() => {
if (!autoCheckEnabled) return;
// Verificar imediatamente ao montar (com delay para não interferir no carregamento)
const initialTimer = setTimeout(() => {
checkUpdate();
}, 5000); // 5 segundos após o app carregar
// Verificar periodicamente
const interval = setInterval(() => {
checkUpdate();
}, 6 * 60 * 60 * 1000); // 6 horas
return () => {
clearTimeout(initialTimer);
clearInterval(interval);
};
}, [autoCheckEnabled, checkUpdate]);
return {
currentVersion,
updateAvailable,
updateInfo,
isChecking,
isDownloading,
downloadProgress,
error,
checkUpdate,
installUpdate,
};
}
+158 -8
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { Message } from './use-chat'; import { Message } from './use-chat';
import { removeMetadataNoise } from '@/lib/metadata'; import { removeMetadataNoise } from '@/lib/metadata';
@@ -10,8 +10,31 @@ export interface SessionSummary {
updated_at: string; updated_at: string;
preview: string; preview: string;
platform: string; platform: string;
match_count?: number;
} }
/** Resultado de carregamento paginado do backend */
interface PaginatedHistoryResult {
messages: Array<{
role: string;
content: string;
metadata?: unknown;
}>;
total_count: number;
has_more: boolean;
}
/** Estado de paginação para uma sessão */
export interface PaginationState {
totalCount: number;
hasMore: boolean;
offset: number;
isLoadingMore: boolean;
}
/** Número de mensagens a carregar por página */
const MESSAGES_PER_PAGE = 30;
const TITLE_MODEL = "qwen2.5:0.5b"; const TITLE_MODEL = "qwen2.5:0.5b";
// Helper to generate fallback title from user's first message // Helper to generate fallback title from user's first message
@@ -41,6 +64,17 @@ export function useChatStorage() {
const [searchQuery, setSearchQuery] = useState<string>(''); const [searchQuery, setSearchQuery] = useState<string>('');
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
// Estado de paginação por sessão
const [paginationState, setPaginationState] = useState<PaginationState>({
totalCount: 0,
hasMore: false,
offset: 0,
isLoadingMore: false,
});
// Ref para evitar múltiplos loads simultâneos
const loadingMoreRef = useRef(false);
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
try { try {
const list = await invoke<SessionSummary[]>('load_chat_sessions'); const list = await invoke<SessionSummary[]>('load_chat_sessions');
@@ -73,23 +107,138 @@ export function useChatStorage() {
} }
}, [loadSessions]); }, [loadSessions]);
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => { /**
* Carrega histórico de uma sessão com paginação (lazy loading)
*
* Por padrão, carrega as últimas MESSAGES_PER_PAGE mensagens.
* Retorna as mensagens e atualiza o estado de paginação.
*/
const loadSessionHistory = useCallback(async (
id: string,
options?: { loadAll?: boolean }
): Promise<Message[]> => {
try { try {
const rawMessages = await invoke<any[]>('load_chat_history', { id }); // Reset pagination state
// Convert role from String to type-safe union and ensure proper types setPaginationState({
return rawMessages.map(msg => ({ totalCount: 0,
hasMore: false,
offset: 0,
isLoadingMore: false,
});
// Se loadAll = true, usa o comando antigo que carrega tudo
if (options?.loadAll) {
const rawMessages = await invoke<Array<{
role: string;
content: string;
metadata?: unknown;
}>>('load_chat_history', { id });
return rawMessages.map(msg => ({
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
? msg.role as 'user' | 'assistant' | 'system'
: 'user' as const,
content: msg.content || '',
metadata: msg.metadata || undefined
}));
}
// Usar paginação: carregar últimas N mensagens
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
id,
limit: MESSAGES_PER_PAGE,
offset: 0
});
// Atualizar estado de paginação
setPaginationState({
totalCount: result.total_count,
hasMore: result.has_more,
offset: result.messages.length,
isLoadingMore: false,
});
// Converter para tipo Message
return result.messages.map(msg => ({
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system') role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
? msg.role as 'user' | 'assistant' | 'system' ? msg.role as 'user' | 'assistant' | 'system'
: 'user' as const, // fallback : 'user' as const,
content: msg.content || '', content: msg.content || '',
metadata: msg.metadata || undefined metadata: msg.metadata || undefined
})); }));
} catch (error) { } catch (error) {
console.error("Failed to load history:", error); console.error("Failed to load history:", error);
return [];
// Fallback: tentar carregar tudo com o comando antigo
try {
const rawMessages = await invoke<Array<{
role: string;
content: string;
metadata?: unknown;
}>>('load_chat_history', { id });
return rawMessages.map(msg => ({
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
? msg.role as 'user' | 'assistant' | 'system'
: 'user' as const,
content: msg.content || '',
metadata: msg.metadata || undefined
}));
} catch {
return [];
}
} }
}, []); }, []);
/**
* Carrega mais mensagens antigas (scroll infinito reverso)
*
* Chamado quando o usuário faz scroll para o topo do chat.
* Retorna as mensagens anteriores que devem ser prepend ao estado.
*/
const loadMoreMessages = useCallback(async (
sessionId: string
): Promise<Message[]> => {
// Evitar múltiplos loads simultâneos
if (loadingMoreRef.current || !paginationState.hasMore) {
return [];
}
loadingMoreRef.current = true;
setPaginationState(prev => ({ ...prev, isLoadingMore: true }));
try {
const result = await invoke<PaginatedHistoryResult>('load_chat_history_paginated', {
id: sessionId,
limit: MESSAGES_PER_PAGE,
offset: paginationState.offset
});
// Atualizar estado de paginação
setPaginationState(prev => ({
...prev,
hasMore: result.has_more,
offset: prev.offset + result.messages.length,
isLoadingMore: false,
}));
// Converter para tipo Message
return result.messages.map(msg => ({
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
? msg.role as 'user' | 'assistant' | 'system'
: 'user' as const,
content: msg.content || '',
metadata: msg.metadata || undefined
}));
} catch (error) {
console.error("Failed to load more messages:", error);
setPaginationState(prev => ({ ...prev, isLoadingMore: false }));
return [];
} finally {
loadingMoreRef.current = false;
}
}, [paginationState.hasMore, paginationState.offset]);
const deleteSession = useCallback(async (id: string) => { const deleteSession = useCallback(async (id: string) => {
try { try {
await invoke('delete_chat_session', { id }); await invoke('delete_chat_session', { id });
@@ -396,6 +545,8 @@ export function useChatStorage() {
setCurrentSessionId, setCurrentSessionId,
loadSessions, loadSessions,
loadSessionHistory, loadSessionHistory,
loadMoreMessages,
paginationState,
saveSession, saveSession,
deleteSession, deleteSession,
isGeneratingTitle, isGeneratingTitle,
@@ -407,4 +558,3 @@ export function useChatStorage() {
isSearching isSearching
}; };
} }
+40 -26
View File
@@ -240,6 +240,29 @@ export function useChat() {
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
const currentSessionIdRef = useRef<string | null>(null); const currentSessionIdRef = useRef<string | null>(null);
const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates
const renderScheduledRef = useRef<boolean>(false);
const rafIdRef = useRef<number | null>(null);
// Função para atualizar o estado visual com o conteúdo acumulado
const flushToState = () => {
renderScheduledRef.current = false;
const displayContent = stripMetadataTag(rawContentRef.current);
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
return [...prev.slice(0, -1), { ...last, content: displayContent }];
}
return [...prev, { role: 'assistant', content: displayContent }];
});
};
// Agendar flush com requestAnimationFrame (limita a ~60fps)
const scheduleFlush = () => {
if (!renderScheduledRef.current) {
renderScheduledRef.current = true;
rafIdRef.current = requestAnimationFrame(flushToState);
}
};
// Listener para eventos do Rust // Listener para eventos do Rust
useEffect(() => { useEffect(() => {
@@ -262,51 +285,38 @@ export function useChat() {
const { content, done } = event.payload; const { content, done } = event.payload;
if (content && content.length > 0) { if (content && content.length > 0) {
// Verificar se este é o mesmo token do último processado (evitar duplicação) // Detecção de duplicatas (MANTER LÓGICA ORIGINAL)
// Isso pode acontecer se o evento for emitido múltiplas vezes
if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) { if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) {
// Token duplicado, ignorar
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30)); console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30));
} }
return; return;
} }
// Atualizar referência do último token
lastTokenRef.current = content; lastTokenRef.current = content;
// Accumulate raw content (including metadata) in ref
rawContentRef.current += content; rawContentRef.current += content;
setMessages(prev => { // MUDANÇA: Agendar flush em vez de chamar setMessages diretamente
const last = prev[prev.length - 1]; scheduleFlush();
if (last && last.role === 'assistant') {
// For display: strip incomplete metadata tag if present
const displayContent = stripMetadataTag(rawContentRef.current);
return [
...prev.slice(0, -1),
{ ...last, content: displayContent }
];
}
// Se não existe mensagem do assistente, criar uma
return [...prev, { role: 'assistant', content: stripMetadataTag(rawContentRef.current) }];
});
} }
if (done) { if (done) {
// Stream terminou, processar metadata final // Cancelar qualquer flush pendente
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
renderScheduledRef.current = false;
}
// Flush IMEDIATO com processamento de metadata
setMessages(prev => { setMessages(prev => {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (last && last.role === 'assistant') { if (last && last.role === 'assistant') {
const rawContent = rawContentRef.current; const rawContent = rawContentRef.current;
const { content: finalContent, metadata } = parseMetadata(rawContent); const { content: finalContent, metadata } = parseMetadata(rawContent);
rawContentRef.current = ''; rawContentRef.current = '';
lastTokenRef.current = ''; // Reset last token tracker lastTokenRef.current = '';
return [...prev.slice(0, -1), { ...last, content: finalContent || last.content, metadata }];
return [
...prev.slice(0, -1),
{ ...last, content: finalContent || last.content, metadata }
];
} }
return prev; return prev;
}); });
@@ -331,6 +341,10 @@ export function useChat() {
unlistenCreated?.(); unlistenCreated?.();
unlistenToken?.(); unlistenToken?.();
unlistenError?.(); unlistenError?.();
// Cancelar RAF pendente
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
}; };
}, []); }, []);
+4 -1
View File
@@ -6,7 +6,9 @@
"dev": "next dev --webpack", "dev": "next dev --webpack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"generate-changelog": "node scripts/generate-changelog.js",
"bump-version": "node scripts/bump-version.js"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -23,6 +25,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tauri-apps/api": "^2.9.0", "@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-updater": "^2.0.0",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+10
View File
@@ -50,6 +50,9 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.9.0 specifier: ^2.9.0
version: 2.9.0 version: 2.9.0
'@tauri-apps/plugin-updater':
specifier: ^2.0.0
version: 2.9.0
'@types/react-syntax-highlighter': '@types/react-syntax-highlighter':
specifier: ^15.5.13 specifier: ^15.5.13
version: 15.5.13 version: 15.5.13
@@ -1153,6 +1156,9 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/[email protected]':
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
'@tybys/[email protected]': '@tybys/[email protected]':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -4228,6 +4234,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.9.4 '@tauri-apps/cli-win32-ia32-msvc': 2.9.4
'@tauri-apps/cli-win32-x64-msvc': 2.9.4 '@tauri-apps/cli-win32-x64-msvc': 2.9.4
'@tauri-apps/[email protected]':
dependencies:
'@tauri-apps/api': 2.9.0
'@tybys/[email protected]': '@tybys/[email protected]':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
+3
View File
@@ -1,3 +1,6 @@
packages:
- '.'
onlyBuiltDependencies: onlyBuiltDependencies:
- sharp - sharp
- unrs-resolver - unrs-resolver
+71
View File
@@ -0,0 +1,71 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const versionType = process.argv[2] || 'patch'; // patch, minor, major
const shouldCommit = process.argv.includes('--commit');
// Validar tipo de versão
if (!['patch', 'minor', 'major'].includes(versionType)) {
console.error('❌ Tipo de versão inválido. Use: patch, minor ou major');
process.exit(1);
}
// Ler package.json
const packageJsonPath = path.join(__dirname, '..', 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
// Bump version
const [major, minor, patch] = packageJson.version.split('.').map(Number);
let newVersion;
switch (versionType) {
case 'major':
newVersion = `${major + 1}.0.0`;
break;
case 'minor':
newVersion = `${major}.${minor + 1}.0`;
break;
case 'patch':
default:
newVersion = `${major}.${minor}.${patch + 1}`;
break;
}
console.log(`📦 Atualizando versão de ${packageJson.version} para ${newVersion}...`);
// Atualizar package.json
packageJson.version = newVersion;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
console.log('✅ package.json atualizado');
// Atualizar tauri.conf.json
const tauriConfPath = path.join(__dirname, '..', 'src-tauri', 'tauri.conf.json');
const tauriConf = JSON.parse(fs.readFileSync(tauriConfPath, 'utf-8'));
tauriConf.version = newVersion;
fs.writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n');
console.log('✅ tauri.conf.json atualizado');
// Atualizar Cargo.toml
const cargoTomlPath = path.join(__dirname, '..', 'src-tauri', 'Cargo.toml');
let cargoToml = fs.readFileSync(cargoTomlPath, 'utf-8');
cargoToml = cargoToml.replace(/^version = ".*"$/m, `version = "${newVersion}"`);
fs.writeFileSync(cargoTomlPath, cargoToml);
console.log('✅ Cargo.toml atualizado');
// Criar commit e tag (opcional)
if (shouldCommit) {
try {
execSync(`git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml`, { stdio: 'inherit' });
execSync(`git commit -m "chore: bump version to ${newVersion}"`, { stdio: 'inherit' });
execSync(`git tag v${newVersion}`, { stdio: 'inherit' });
console.log(`✅ Commit e tag v${newVersion} criados`);
console.log(`\n📝 Próximo passo: git push && git push --tags`);
} catch (error) {
console.error('❌ Erro ao criar commit/tag:', error.message);
process.exit(1);
}
}
console.log(`\n✅ Versão atualizada para ${newVersion}`);
+205
View File
@@ -0,0 +1,205 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
// Ler versão do package.json
const packageJsonPath = path.join(__dirname, '..', 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
const currentVersion = packageJson.version;
// Verificar se estamos em um repositório Git
let isGitRepo = false;
try {
execSync('git rev-parse --git-dir', { stdio: 'ignore', cwd: path.join(__dirname, '..') });
isGitRepo = true;
} catch (error) {
console.warn('⚠️ Não é um repositório Git. Gerando changelog vazio.');
}
// Função para sanitizar mensagens de commit
function sanitizeMessage(message) {
return message
.replace(/\n/g, ' ')
.replace(/\r/g, '')
.replace(/"/g, '\\"')
.trim();
}
// Função para extrair tipo e mensagem do commit
function parseCommitMessage(fullMessage) {
const featMatch = fullMessage.match(/^feat(\(.+?\))?:\s*(.+)$/i);
const fixMatch = fullMessage.match(/^fix(\(.+?\))?:\s*(.+)$/i);
if (featMatch) {
return { type: 'feat', message: featMatch[2] || featMatch[0] };
}
if (fixMatch) {
return { type: 'fix', message: fixMatch[2] || fixMatch[0] };
}
return null;
}
// Função para obter tags de versão
function getVersionTags() {
try {
const tags = execSync('git tag --sort=-version:refname', {
encoding: 'utf-8',
cwd: path.join(__dirname, '..'),
})
.trim()
.split('\n')
.filter(Boolean)
.filter(tag => /^v?\d+\.\d+\.\d+/.test(tag));
return tags;
} catch (error) {
return [];
}
}
// Função para obter commits entre duas tags ou desde o início
function getCommitsBetweenTags(fromTag, toTag) {
try {
let range = '';
if (fromTag && toTag) {
range = `${fromTag}..${toTag}`;
} else if (toTag) {
range = toTag;
} else {
range = 'HEAD';
}
const logFormat = '%H|%an|%ad|%s';
const dateFormat = '--date=iso-strict';
const output = execSync(
`git log ${range} --pretty=format:"${logFormat}" --date=${dateFormat}`,
{
encoding: 'utf-8',
cwd: path.join(__dirname, '..'),
}
);
return output
.trim()
.split('\n')
.filter(Boolean)
.map(line => {
const [hash, author, date, message] = line.split('|');
const parsed = parseCommitMessage(message);
if (!parsed) return null;
return {
hash: hash.substring(0, 7),
type: parsed.type,
message: sanitizeMessage(parsed.message),
author: author.trim(),
date: date.trim(),
};
})
.filter(Boolean);
} catch (error) {
return [];
}
}
// Função principal para gerar changelog
function generateChangelog() {
const changelogPath = path.join(__dirname, '..', 'data', 'changelog.json');
const dataDir = path.dirname(changelogPath);
// Criar diretório data se não existir
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
if (!isGitRepo) {
// Gerar changelog vazio se não for repositório Git
const emptyChangelog = {
version: currentVersion,
generatedAt: new Date().toISOString(),
releases: [],
};
fs.writeFileSync(changelogPath, JSON.stringify(emptyChangelog, null, 2), 'utf-8');
console.log('✅ Changelog vazio gerado (não é repositório Git)');
return;
}
const releases = [];
const versionTags = getVersionTags();
if (versionTags.length > 0) {
// Agrupar por tags de versão
for (let i = 0; i < versionTags.length; i++) {
const currentTag = versionTags[i];
const nextTag = i < versionTags.length - 1 ? versionTags[i + 1] : null;
const commits = getCommitsBetweenTags(nextTag, currentTag);
if (commits.length > 0) {
// Extrair versão da tag (remover 'v' prefix se existir)
const version = currentTag.replace(/^v/, '');
const releaseDate = commits[0]?.date || new Date().toISOString();
releases.push({
version,
date: releaseDate.split('T')[0],
commits,
});
}
}
// Adicionar commits não versionados (após a última tag)
const unreleasedCommits = getCommitsBetweenTags(versionTags[0], 'HEAD');
if (unreleasedCommits.length > 0) {
releases.unshift({
version: 'unreleased',
date: unreleasedCommits[0]?.date.split('T')[0] || new Date().toISOString().split('T')[0],
commits: unreleasedCommits,
});
}
} else {
// Sem tags, agrupar por data (últimos 30 dias ou todos se menos)
const allCommits = getCommitsBetweenTags(null, 'HEAD');
if (allCommits.length > 0) {
// Agrupar por data
const commitsByDate = {};
allCommits.forEach(commit => {
const date = commit.date.split('T')[0];
if (!commitsByDate[date]) {
commitsByDate[date] = [];
}
commitsByDate[date].push(commit);
});
// Criar releases por data (ordenado por data decrescente)
Object.keys(commitsByDate)
.sort((a, b) => b.localeCompare(a))
.forEach(date => {
releases.push({
version: 'unreleased',
date,
commits: commitsByDate[date],
});
});
}
}
const changelog = {
version: currentVersion,
generatedAt: new Date().toISOString(),
releases,
};
fs.writeFileSync(changelogPath, JSON.stringify(changelog, null, 2), 'utf-8');
console.log(`✅ Changelog gerado: ${releases.length} release(s) encontrada(s)`);
console.log(` Total de commits: ${releases.reduce((sum, r) => sum + r.commits.length, 0)}`);
}
// Executar
try {
generateChangelog();
} catch (error) {
console.error('❌ Erro ao gerar changelog:', error.message);
process.exit(1);
}
+679 -15
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -44,6 +44,14 @@ futures-util = "0.3"
regex = "1.10" regex = "1.10"
walkdir = "2.5" walkdir = "2.5"
tauri-plugin-autostart = "2" tauri-plugin-autostart = "2"
tauri-plugin-updater = "2"
dirs = "5.0" dirs = "5.0"
rfd = "0.14" rfd = "0.14"
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
# ONNX Runtime para embeddings locais (ranking de relevância)
# Usando load-dynamic para evitar conflito de RuntimeLibrary (MD vs MT) no Windows
# entre ort (dynamic) e tokenizers/esaxx-rs (static)
ort = { version = "2.0.0-rc.10", features = ["load-dynamic"] }
ndarray = "0.16"
tokenizers = "0.21"
+2 -1
View File
@@ -13,6 +13,7 @@
"core:window:allow-maximize", "core:window:allow-maximize",
"core:window:allow-unmaximize", "core:window:allow-unmaximize",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-is-maximized" "core:window:allow-is-maximized",
"updater:default"
] ]
} }
+257 -60
View File
@@ -22,6 +22,13 @@ pub struct ChatMessage {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// Resultado de busca de sessões com contagem de matches
#[derive(Debug, Clone)]
pub struct SearchSessionResult {
pub session: ChatSession,
pub match_count: i64,
}
pub struct Database { pub struct Database {
conn: Connection, conn: Connection,
} }
@@ -49,6 +56,19 @@ impl Database {
let db_path = app_data_dir.join("ollahub.db"); let db_path = app_data_dir.join("ollahub.db");
let conn = Connection::open(&db_path)?; let conn = Connection::open(&db_path)?;
// Otimizações de performance do SQLite
// WAL mode permite leituras e escritas simultâneas (evita bloqueio da UI)
// synchronous=NORMAL reduz fsync sem perder segurança
// cache_size maior acelera operações frequentes
// temp_store=MEMORY usa RAM para tabelas temporárias
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=10000;
PRAGMA temp_store=MEMORY;
PRAGMA foreign_keys=ON;"
)?;
let db = Self { conn }; let db = Self { conn };
db.init_schema()?; db.init_schema()?;
@@ -370,6 +390,54 @@ impl Database {
Ok(self.conn.last_insert_rowid()) Ok(self.conn.last_insert_rowid())
} }
/// Salva múltiplas mensagens de uma sessão em uma transação
///
/// Remove mensagens existentes da sessão antes de inserir as novas
/// para garantir que não haja duplicatas.
pub fn save_messages_batch(
&self,
session_id: &str,
messages: &[ChatMessage],
) -> SqliteResult<()> {
// Usar execute_batch para executar múltiplas operações atomicamente
// WAL mode permite isso de forma segura mesmo sem transação explícita
// Remover mensagens existentes da sessão (para evitar duplicatas)
self.conn.execute(
"DELETE FROM messages WHERE session_id = ?1",
params![session_id],
)?;
// Inserir todas as mensagens
let mut stmt = self.conn.prepare(
"INSERT INTO messages (session_id, role, content, metadata, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)"
)?;
for message in messages {
stmt.execute(params![
message.session_id,
message.role,
message.content,
message.metadata,
message.created_at.to_rfc3339()
])?;
}
// Atualizar updated_at da sessão com a data da última mensagem
if let Some(last_message) = messages.last() {
self.conn.execute(
"UPDATE sessions SET updated_at = ?1 WHERE id = ?2",
params![
last_message.created_at.to_rfc3339(),
session_id
],
)?;
}
Ok(())
}
/// Busca todas as mensagens de uma sessão /// Busca todas as mensagens de uma sessão
pub fn get_messages(&self, session_id: &str) -> SqliteResult<Vec<ChatMessage>> { pub fn get_messages(&self, session_id: &str) -> SqliteResult<Vec<ChatMessage>> {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
@@ -399,6 +467,88 @@ impl Database {
Ok(messages) Ok(messages)
} }
/// Busca mensagens de uma sessão com paginação (lazy loading)
///
/// Retorna as últimas `limit` mensagens a partir do `offset`.
/// O offset conta do final (0 = últimas mensagens, 20 = 20 mensagens antes das últimas).
///
/// Parâmetros:
/// - session_id: ID da sessão
/// - limit: número máximo de mensagens a retornar
/// - offset: número de mensagens a pular do final (0 = começar das últimas)
///
/// Retorna: (mensagens em ordem ASC, total_count, has_more)
pub fn get_messages_paginated(
&self,
session_id: &str,
limit: usize,
offset: usize,
) -> SqliteResult<(Vec<ChatMessage>, usize, bool)> {
// Primeiro, obter o total de mensagens
let total_count: usize = self.conn.query_row(
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
params![session_id],
|row| row.get(0),
)?;
// Se não há mensagens, retornar vazio
if total_count == 0 {
return Ok((Vec::new(), 0, false));
}
// Estratégia: usar subquery para pegar as últimas N mensagens ordenadas DESC,
// depois ordenar ASC para manter ordem cronológica
//
// Se offset=0 e limit=30: queremos as últimas 30 mensagens
// Se offset=30 e limit=30: queremos as 30 mensagens antes das últimas 30
let real_limit = std::cmp::min(limit, total_count.saturating_sub(offset));
if real_limit == 0 {
return Ok((Vec::new(), total_count, false));
}
// Query: pegar as últimas (offset + limit) mensagens ordenadas DESC,
// depois ordenar ASC e pegar as primeiras 'limit' (que são as mais antigas do conjunto)
let mut stmt = self.conn.prepare(
"SELECT id, session_id, role, content, metadata, created_at
FROM (
SELECT id, session_id, role, content, metadata, created_at
FROM messages
WHERE session_id = ?1
ORDER BY created_at DESC
LIMIT ?2
) AS recent_messages
ORDER BY created_at ASC
LIMIT ?3"
)?;
// Precisamos pegar (offset + limit) mensagens do final para depois pegar as primeiras 'limit'
let fetch_limit = offset + limit;
let rows = stmt.query_map(params![session_id, fetch_limit, real_limit], |row| {
Ok(ChatMessage {
id: Some(row.get(0)?),
session_id: row.get(1)?,
role: row.get(2)?,
content: row.get(3)?,
metadata: row.get(4)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(5, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
})
})?;
let mut messages = Vec::new();
for row in rows {
messages.push(row?);
}
// has_more = ainda há mensagens mais antigas para carregar
let has_more = offset + messages.len() < total_count;
Ok((messages, total_count, has_more))
}
/// Salva um documento RAG /// Salva um documento RAG
pub fn save_rag_document( pub fn save_rag_document(
&self, &self,
@@ -446,89 +596,136 @@ impl Database {
/// Busca sessões por query (título ou conteúdo de mensagens) /// Busca sessões por query (título ou conteúdo de mensagens)
/// Retorna resultados ordenados por relevância (match no título > match no conteúdo) /// Retorna resultados ordenados por relevância (match no título > match no conteúdo)
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<ChatSession>> { /// Inclui contagem de matches para navegação
pub fn search_sessions(&self, query: &str, limit: usize) -> SqliteResult<Vec<SearchSessionResult>> {
if query.trim().is_empty() { if query.trim().is_empty() {
// Se query vazia, retornar todas as sessões ordenadas por updated_at // Se query vazia, retornar todas as sessões ordenadas por updated_at com match_count = 0
return self.list_sessions(); let sessions = self.list_sessions()?;
return Ok(sessions.into_iter().map(|session| SearchSessionResult {
session,
match_count: 0,
}).collect());
} }
// Escapar caracteres especiais para FTS5 // Escapar caracteres especiais para FTS5
let escaped_query = query.replace('"', "\"\""); let escaped_query = query.replace('"', "\"\"");
let fts_query = format!("\"{}\"", escaped_query); let fts_query = format!("\"{}\"", escaped_query);
// Busca combinada: título (peso 1.0) + conteúdo (peso 0.5) // Busca simplificada: primeiro buscar por título, depois por conteúdo
let mut stmt = self.conn.prepare( // Usando abordagem em duas etapas para evitar problemas com bm25 em CTEs
"WITH title_matches AS (
SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
bm25(sessions_fts) as rank
FROM sessions s
JOIN sessions_fts ON s.rowid = sessions_fts.rowid
WHERE sessions_fts MATCH ?1
ORDER BY rank
LIMIT ?2
),
content_matches AS (
SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at,
bm25(messages_fts) * 0.5 as rank
FROM sessions s
JOIN messages m ON s.id = m.session_id
JOIN messages_fts ON m.rowid = messages_fts.rowid
WHERE messages_fts MATCH ?1
ORDER BY rank
LIMIT ?2
)
SELECT id, title, emoji, created_at, updated_at
FROM (
SELECT * FROM title_matches
UNION
SELECT * FROM content_matches
)
ORDER BY rank DESC, updated_at DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![fts_query, limit], |row| { // Etapa 1: Buscar sessões por título (FTS5)
Ok(ChatSession { let mut title_sessions: Vec<SearchSessionResult> = Vec::new();
id: row.get(0)?, {
title: row.get(1)?, let mut stmt = self.conn.prepare(
emoji: row.get(2)?, "SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) FROM sessions s
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))? JOIN sessions_fts ON s.rowid = sessions_fts.rowid
.with_timezone(&Utc), WHERE sessions_fts MATCH ?1
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) ORDER BY s.updated_at DESC
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))? LIMIT ?2"
.with_timezone(&Utc), )?;
})
})?;
let mut sessions = Vec::new(); let rows = stmt.query_map(params![&fts_query, limit], |row| {
for row in rows { Ok(SearchSessionResult {
sessions.push(row?); session: ChatSession {
id: row.get(0)?,
title: row.get(1)?,
emoji: row.get(2)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
},
match_count: 1, // Match no título conta como 1
})
})?;
for row in rows {
title_sessions.push(row?);
}
} }
// Etapa 2: Buscar sessões por conteúdo de mensagens (FTS5)
let mut content_sessions: Vec<(String, i64)> = Vec::new(); // (session_id, match_count)
{
let mut stmt = self.conn.prepare(
"SELECT m.session_id, COUNT(*) as match_count
FROM messages m
JOIN messages_fts ON m.rowid = messages_fts.rowid
WHERE messages_fts MATCH ?1
GROUP BY m.session_id
ORDER BY match_count DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![&fts_query, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for row in rows {
content_sessions.push(row?);
}
}
// Combinar resultados: priorizar matches por título, adicionar matches por conteúdo
let mut session_map: std::collections::HashMap<String, SearchSessionResult> = std::collections::HashMap::new();
// Adicionar sessões encontradas por título
for session in title_sessions {
session_map.insert(session.session.id.clone(), session);
}
// Adicionar ou atualizar com sessões encontradas por conteúdo
for (session_id, match_count) in content_sessions {
if let Some(existing) = session_map.get_mut(&session_id) {
// Atualizar match_count se já existe
existing.match_count = match_count;
} else {
// Buscar dados da sessão
if let Ok(Some(session)) = self.get_session(&session_id) {
session_map.insert(session_id, SearchSessionResult {
session,
match_count,
});
}
}
}
// Converter para vetor e ordenar por updated_at
let mut sessions: Vec<SearchSessionResult> = session_map.into_values().collect();
sessions.sort_by(|a, b| b.session.updated_at.cmp(&a.session.updated_at));
// Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback) // Se não encontrou resultados com FTS, tentar busca simples com LIKE (fallback)
if sessions.is_empty() { if sessions.is_empty() {
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
"SELECT DISTINCT s.id, s.title, s.emoji, s.created_at, s.updated_at "SELECT s.id, s.title, s.emoji, s.created_at, s.updated_at,
COUNT(CASE WHEN m.content LIKE ?1 THEN 1 END) as match_count
FROM sessions s FROM sessions s
LEFT JOIN messages m ON s.id = m.session_id LEFT JOIN messages m ON s.id = m.session_id
WHERE s.title LIKE ?1 OR m.content LIKE ?1 WHERE s.title LIKE ?1 OR m.content LIKE ?1
GROUP BY s.id, s.title, s.emoji, s.created_at, s.updated_at
ORDER BY s.updated_at DESC ORDER BY s.updated_at DESC
LIMIT ?2" LIMIT ?2"
)?; )?;
let like_query = format!("%{}%", query); let like_query = format!("%{}%", query);
let rows = stmt.query_map(params![like_query, limit], |row| { let rows = stmt.query_map(params![like_query, limit], |row| {
Ok(ChatSession { Ok(SearchSessionResult {
id: row.get(0)?, session: ChatSession {
title: row.get(1)?, id: row.get(0)?,
emoji: row.get(2)?, title: row.get(1)?,
created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?) emoji: row.get(2)?,
.map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))? created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(3)?)
.with_timezone(&Utc), .map_err(|_| rusqlite::Error::InvalidColumnType(3, "TEXT".to_string(), rusqlite::types::Type::Text))?
updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?) .with_timezone(&Utc),
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))? updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(4)?)
.with_timezone(&Utc), .map_err(|_| rusqlite::Error::InvalidColumnType(4, "TEXT".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc),
},
match_count: row.get(5)?,
}) })
})?; })?;
+566
View File
@@ -0,0 +1,566 @@
//! Módulo de Embeddings ONNX para ranking de relevância
//!
//! Usa o modelo all-MiniLM-L6-v2 para gerar embeddings de texto
//! e calcular similaridade de cosseno para ranking de resultados de busca.
use anyhow::{Result, anyhow};
use ndarray::Array2;
use ort::session::{Session, builder::GraphOptimizationLevel};
use ort::value::Value;
use std::io::Read;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use tokenizers::Tokenizer;
/// Flag para controlar se o ort já foi inicializado
static ORT_INITIALIZED: OnceLock<Result<(), String>> = OnceLock::new();
/// Dimensão dos embeddings do modelo all-MiniLM-L6-v2
pub const EMBEDDING_DIM: usize = 384;
/// Tamanho máximo de tokens para o modelo
const MAX_SEQ_LENGTH: usize = 256;
/// Global lazy-initialized embedding model (com Mutex para permitir mutabilidade)
static EMBEDDING_MODEL: OnceLock<Result<Arc<Mutex<EmbeddingModel>>, String>> = OnceLock::new();
/// Modelo de embeddings para cálculo de similaridade semântica
pub struct EmbeddingModel {
session: Session,
tokenizer: Tokenizer,
}
impl EmbeddingModel {
/// Carrega o modelo ONNX e tokenizer
pub fn new(model_path: &str, tokenizer_path: &str) -> Result<Self> {
// Verificar se arquivos existem
if !Path::new(model_path).exists() {
return Err(anyhow!("Model file not found: {}", model_path));
}
if !Path::new(tokenizer_path).exists() {
return Err(anyhow!("Tokenizer file not found: {}", tokenizer_path));
}
log::info!("[Embeddings] Loading ONNX model from: {}", model_path);
// Criar sessão ONNX
let session = Session::builder()?
.with_optimization_level(GraphOptimizationLevel::Level3)?
.commit_from_file(model_path)?;
// Carregar tokenizer
let tokenizer = Tokenizer::from_file(tokenizer_path)
.map_err(|e| anyhow!("Failed to load tokenizer: {}", e))?;
log::info!("[Embeddings] Model loaded successfully");
Ok(Self { session, tokenizer })
}
/// Gera embedding para um texto
pub fn embed(&mut self, text: &str) -> Result<Vec<f32>> {
// Tokenizar texto
let encoding = self.tokenizer
.encode(text, true)
.map_err(|e| anyhow!("Tokenization failed: {}", e))?;
let mut input_ids: Vec<i64> = encoding.get_ids()
.iter()
.map(|&id| id as i64)
.collect();
let mut attention_mask: Vec<i64> = encoding.get_attention_mask()
.iter()
.map(|&m| m as i64)
.collect();
let mut token_type_ids: Vec<i64> = encoding.get_type_ids()
.iter()
.map(|&t| t as i64)
.collect();
// Truncar/pad para MAX_SEQ_LENGTH
input_ids.truncate(MAX_SEQ_LENGTH);
attention_mask.truncate(MAX_SEQ_LENGTH);
token_type_ids.truncate(MAX_SEQ_LENGTH);
while input_ids.len() < MAX_SEQ_LENGTH {
input_ids.push(0);
attention_mask.push(0);
token_type_ids.push(0);
}
// Criar arrays para inferência (batch size = 1)
let input_ids_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), input_ids)?;
let attention_mask_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), attention_mask)?;
let token_type_ids_array = Array2::from_shape_vec((1, MAX_SEQ_LENGTH), token_type_ids)?;
// Criar inputs ONNX
let input_ids_value = Value::from_array(input_ids_array)?;
let attention_mask_value = Value::from_array(attention_mask_array)?;
let token_type_ids_value = Value::from_array(token_type_ids_array)?;
// Executar inferência usando vetor de inputs
let inputs: Vec<(std::borrow::Cow<str>, ort::session::SessionInputValue)> = vec![
("input_ids".into(), input_ids_value.into()),
("attention_mask".into(), attention_mask_value.into()),
("token_type_ids".into(), token_type_ids_value.into()),
];
let outputs = self.session.run(inputs)?;
// Extrair output (last_hidden_state ou sentence_embedding dependendo do modelo)
// Para all-MiniLM-L6-v2, fazemos mean pooling do last_hidden_state
let output = outputs.get("last_hidden_state")
.or_else(|| outputs.get("sentence_embedding"))
.ok_or_else(|| anyhow!("Output tensor not found"))?;
let (shape, raw_data) = output.try_extract_tensor::<f32>()?;
// Shape implementa Deref para [i64], então podemos usar diretamente
let dims: &[i64] = &*shape;
let data: &[f32] = raw_data;
// Mean pooling: média ao longo da dimensão de sequência
let embedding = if dims.len() == 3 {
// Shape: (batch, seq_len, hidden_dim)
let seq_len = dims[1] as usize;
let hidden_dim = dims[2] as usize;
let mut pooled = vec![0.0f32; hidden_dim];
for i in 0..seq_len {
for j in 0..hidden_dim {
let idx = i * hidden_dim + j;
pooled[j] += data[idx];
}
}
for v in &mut pooled {
*v /= seq_len as f32;
}
// Normalizar L2
let norm: f32 = pooled.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for v in &mut pooled {
*v /= norm;
}
}
pooled
} else {
// Shape: (batch, hidden_dim) - já pooled
let hidden_dim = dims[1] as usize;
let mut embedding = Vec::with_capacity(hidden_dim);
for j in 0..hidden_dim {
embedding.push(data[j]);
}
// Normalizar L2
let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 0.0 {
for v in &mut embedding {
*v /= norm;
}
}
embedding
};
Ok(embedding)
}
/// Calcula embeddings em batch (mais eficiente para múltiplos textos)
pub fn embed_batch(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
// Para simplificar, processa um por um (pode ser otimizado para batch real)
let mut results = Vec::with_capacity(texts.len());
for text in texts {
results.push(self.embed(text)?);
}
Ok(results)
}
}
/// Calcula similaridade de cosseno entre dois vetores
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
dot / (norm_a * norm_b)
}
/// URLs para download do modelo (Hugging Face)
const MODEL_URL: &str = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx";
const TOKENIZER_URL: &str = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json";
/// URL para download da biblioteca ONNX Runtime (Windows x64)
#[cfg(target_os = "windows")]
const ORT_DLL_URL: &str = "https://github.com/microsoft/onnxruntime/releases/download/v1.20.1/onnxruntime-win-x64-1.20.1.zip";
/// Inicializa o ONNX Runtime com a biblioteca dinâmica
fn init_ort_runtime(app_data_dir: &Path) -> Result<()> {
let result = ORT_INITIALIZED.get_or_init(|| {
let ort_dir = app_data_dir.join("ort");
#[cfg(target_os = "windows")]
let dll_path = ort_dir.join("onnxruntime.dll");
#[cfg(target_os = "macos")]
let dll_path = ort_dir.join("libonnxruntime.dylib");
#[cfg(target_os = "linux")]
let dll_path = ort_dir.join("libonnxruntime.so");
if dll_path.exists() {
// Definir variável de ambiente para o ort encontrar a DLL
std::env::set_var("ORT_DYLIB_PATH", &dll_path);
log::info!("[Embeddings] ORT_DYLIB_PATH set to: {:?}", dll_path);
Ok(())
} else {
// Tentar usar a biblioteca do sistema
log::warn!("[Embeddings] ONNX Runtime library not found at {:?}, will try system path", dll_path);
Ok(())
}
});
match result {
Ok(()) => Ok(()),
Err(e) => Err(anyhow!("{}", e))
}
}
/// Baixa um arquivo de uma URL para o caminho especificado
async fn download_file(url: &str, path: &Path) -> Result<()> {
log::info!("[Embeddings] Downloading: {} -> {:?}", url, path);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300)) // 5 min timeout
.build()?;
let response = client.get(url).send().await?;
if !response.status().is_success() {
return Err(anyhow!("Download failed with status: {}", response.status()));
}
let bytes = response.bytes().await?;
// Criar diretório pai se não existir
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, bytes)?;
log::info!("[Embeddings] Downloaded successfully: {:?}", path);
Ok(())
}
/// Baixa e extrai a biblioteca ONNX Runtime
#[cfg(target_os = "windows")]
async fn ensure_ort_library(app_data_dir: &Path) -> Result<()> {
let ort_dir = app_data_dir.join("ort");
let dll_path = ort_dir.join("onnxruntime.dll");
if dll_path.exists() {
log::info!("[Embeddings] ONNX Runtime library already exists");
return Ok(());
}
log::info!("[Embeddings] Downloading ONNX Runtime library...");
// Criar diretório
std::fs::create_dir_all(&ort_dir)?;
// Baixar arquivo zip
let zip_path = ort_dir.join("onnxruntime.zip");
download_file(ORT_DLL_URL, &zip_path).await?;
// Extrair DLL do zip
log::info!("[Embeddings] Extracting ONNX Runtime library...");
let file = std::fs::File::open(&zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
// Procurar pela DLL dentro do zip
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let name = entry.name().to_string();
if name.ends_with("onnxruntime.dll") {
let mut outfile = std::fs::File::create(&dll_path)?;
std::io::copy(&mut entry, &mut outfile)?;
log::info!("[Embeddings] Extracted onnxruntime.dll");
break;
}
}
// Remover arquivo zip
let _ = std::fs::remove_file(&zip_path);
if !dll_path.exists() {
return Err(anyhow!("Failed to extract onnxruntime.dll from archive"));
}
Ok(())
}
#[cfg(not(target_os = "windows"))]
async fn ensure_ort_library(_app_data_dir: &Path) -> Result<()> {
// Em outros sistemas, assumimos que a biblioteca está no sistema
log::info!("[Embeddings] Using system ONNX Runtime library");
Ok(())
}
/// Garante que os arquivos do modelo existem, baixando se necessário
pub async fn ensure_model_files(app_data_dir: &Path) -> Result<(std::path::PathBuf, std::path::PathBuf)> {
let models_dir = app_data_dir.join("models");
let model_path = models_dir.join("all-MiniLM-L6-v2.onnx");
let tokenizer_path = models_dir.join("tokenizer.json");
// Criar diretório de modelos
std::fs::create_dir_all(&models_dir)?;
// Baixar biblioteca ONNX Runtime se necessário (apenas Windows)
ensure_ort_library(app_data_dir).await?;
// Inicializar ort com o caminho da DLL
init_ort_runtime(app_data_dir)?;
// Baixar modelo se não existir
if !model_path.exists() {
log::info!("[Embeddings] Model not found, downloading...");
download_file(MODEL_URL, &model_path).await?;
}
// Baixar tokenizer se não existir
if !tokenizer_path.exists() {
log::info!("[Embeddings] Tokenizer not found, downloading...");
download_file(TOKENIZER_URL, &tokenizer_path).await?;
}
Ok((model_path, tokenizer_path))
}
/// Verifica se o modelo está disponível
pub fn is_model_available(app_data_dir: &Path) -> bool {
let models_dir = app_data_dir.join("models");
let model_path = models_dir.join("all-MiniLM-L6-v2.onnx");
let tokenizer_path = models_dir.join("tokenizer.json");
model_path.exists() && tokenizer_path.exists()
}
/// Obtém ou inicializa o modelo global de embeddings
pub fn get_or_init_model(app_data_dir: &Path) -> Result<Arc<Mutex<EmbeddingModel>>> {
// Inicializar ort com o caminho da DLL antes de criar o modelo
init_ort_runtime(app_data_dir)?;
let result = EMBEDDING_MODEL.get_or_init(|| {
let model_path = app_data_dir.join("models").join("all-MiniLM-L6-v2.onnx");
let tokenizer_path = app_data_dir.join("models").join("tokenizer.json");
match EmbeddingModel::new(
model_path.to_str().unwrap_or(""),
tokenizer_path.to_str().unwrap_or("")
) {
Ok(model) => Ok(Arc::new(Mutex::new(model))),
Err(e) => Err(format!("Failed to load embedding model: {}", e))
}
});
match result {
Ok(model) => Ok(model.clone()),
Err(e) => Err(anyhow!("{}", e))
}
}
/// Calcula scores de relevância para múltiplos textos em relação a uma query
pub fn rank_by_relevance(
model: &mut EmbeddingModel,
query: &str,
texts: &[&str],
) -> Result<Vec<(usize, f32)>> {
let query_embedding = model.embed(query)?;
let mut scores: Vec<(usize, f32)> = Vec::with_capacity(texts.len());
for (idx, text) in texts.iter().enumerate() {
let text_embedding = model.embed(text).unwrap_or_else(|_| vec![0.0; EMBEDDING_DIM]);
let score = cosine_similarity(&query_embedding, &text_embedding);
scores.push((idx, score));
}
// Ordenar por score decrescente
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(scores)
}
/// Poda o contexto mantendo apenas os parágrafos mais relevantes
///
/// Argumentos:
/// - model: Modelo de embeddings
/// - query: Query do usuário
/// - context: Contexto completo (texto separado por parágrafos)
/// - max_tokens: Número máximo de tokens (aproximado por palavras)
/// - min_score: Score mínimo de relevância (0.0 a 1.0)
///
/// Retorna: Contexto podado com os parágrafos mais relevantes
pub fn prune_context(
model: &mut EmbeddingModel,
query: &str,
context: &str,
max_tokens: usize,
min_score: f32,
) -> Result<String> {
// Dividir contexto em parágrafos
let paragraphs: Vec<&str> = context
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty() && p.len() > 20) // Filtrar parágrafos muito curtos
.collect();
if paragraphs.is_empty() {
return Ok(context.to_string());
}
// Calcular embedding da query
let query_embedding = model.embed(query)?;
// Calcular scores para cada parágrafo
let mut scored_paragraphs: Vec<(f32, &str, usize)> = Vec::with_capacity(paragraphs.len());
for (idx, &p) in paragraphs.iter().enumerate() {
let embedding = model.embed(p).unwrap_or_else(|_| vec![0.0; EMBEDDING_DIM]);
let score = cosine_similarity(&query_embedding, &embedding);
scored_paragraphs.push((score, p, idx));
}
// Ordenar por score decrescente
scored_paragraphs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
// Filtrar por score mínimo
scored_paragraphs.retain(|(score, _, _)| *score >= min_score);
// Selecionar parágrafos até atingir max_tokens
let mut result = Vec::new();
let mut total_tokens = 0;
for (score, paragraph, original_idx) in scored_paragraphs {
let paragraph_tokens = paragraph.split_whitespace().count();
if total_tokens + paragraph_tokens > max_tokens {
// Se ainda não temos nenhum parágrafo, incluir o primeiro mesmo que exceda
if result.is_empty() {
result.push((original_idx, paragraph));
}
break;
}
result.push((original_idx, paragraph));
total_tokens += paragraph_tokens;
}
// Ordenar pelo índice original para manter a ordem do contexto
result.sort_by_key(|(idx, _)| *idx);
// Reconstruir contexto
let pruned: Vec<&str> = result.into_iter().map(|(_, p)| p).collect();
log::info!(
"[Embeddings] Context pruned: {} paragraphs -> {} paragraphs, {} tokens",
paragraphs.len(),
pruned.len(),
total_tokens
);
Ok(pruned.join("\n\n"))
}
/// Versão simplificada de poda usando apenas BM25-like (sem embeddings)
/// Útil quando o modelo não está disponível
pub fn prune_context_bm25(
query: &str,
context: &str,
max_tokens: usize,
) -> String {
let paragraphs: Vec<&str> = context
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty() && p.len() > 20)
.collect();
if paragraphs.is_empty() {
return context.to_string();
}
let query_lower = query.to_lowercase();
let query_terms: Vec<&str> = query_lower
.split_whitespace()
.filter(|w| w.len() > 2)
.collect();
// Calcular score BM25-like simples
let mut scored: Vec<(f32, &str, usize)> = paragraphs.iter()
.enumerate()
.map(|(idx, &p)| {
let p_lower = p.to_lowercase();
let mut score = 0.0f32;
for term in &query_terms {
let count = p_lower.matches(term).count();
if count > 0 {
// TF com saturação logarítmica
score += (1.0 + (count as f32).ln()) * (1.0 / (1.0 + query_terms.len() as f32));
}
}
(score, p, idx)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let mut result = Vec::new();
let mut total_tokens = 0;
for (_, paragraph, original_idx) in scored {
let paragraph_tokens = paragraph.split_whitespace().count();
if total_tokens + paragraph_tokens > max_tokens {
if result.is_empty() {
result.push((original_idx, paragraph));
}
break;
}
result.push((original_idx, paragraph));
total_tokens += paragraph_tokens;
}
result.sort_by_key(|(idx, _)| *idx);
let pruned: Vec<&str> = result.into_iter().map(|(_, p)| p).collect();
pruned.join("\n\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cosine_similarity() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![1.0, 0.0, 0.0];
assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
let c = vec![0.0, 1.0, 0.0];
assert!((cosine_similarity(&a, &c) - 0.0).abs() < 0.001);
let d = vec![-1.0, 0.0, 0.0];
assert!((cosine_similarity(&a, &d) - (-1.0)).abs() < 0.001);
}
}
+502 -15
View File
@@ -21,6 +21,7 @@ mod sources_config;
mod system_monitor; mod system_monitor;
mod intent_classifier; mod intent_classifier;
mod db; mod db;
mod embeddings;
use web_scraper::{ use web_scraper::{
ScrapedContent, ScrapedContent,
@@ -114,6 +115,8 @@ struct SessionSummary {
updated_at: DateTime<Utc>, updated_at: DateTime<Utc>,
preview: String, preview: String,
platform: String, platform: String,
#[serde(skip_serializing_if = "Option::is_none")]
match_count: Option<usize>,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -395,6 +398,57 @@ fn save_chat_session(
fs::rename(&temp_path, &file_path) fs::rename(&temp_path, &file_path)
.map_err(|e| format!("Failed to rename temp file to session file: {}", e))?; .map_err(|e| format!("Failed to rename temp file to session file: {}", e))?;
// Também salvar no SQLite (sistema novo) para melhor performance e paginação
// Se falhar, apenas logar erro mas não falhar a operação (compatibilidade)
use db::Database;
match Database::new(&app_handle) {
Ok(db) => {
// Criar/atualizar sessão no SQLite
let db_session = db::ChatSession {
id: session.id.clone(),
title: session.title.clone(),
emoji: "💬".to_string(), // Emoji padrão
created_at: session.created_at,
updated_at: session.updated_at,
};
if let Err(e) = db.save_session(&db_session) {
log::warn!("Failed to save session to SQLite (continuing with JSON only): {}", e);
} else {
// Converter Message para ChatMessage e salvar no SQLite
// Preservar ordem usando timestamps incrementais baseados no índice
let chat_messages: Vec<db::ChatMessage> = session.messages.iter().enumerate().map(|(idx, msg)| {
let metadata_str = msg.metadata.as_ref()
.and_then(|m| serde_json::to_string(m).ok());
// Criar timestamp incremental para preservar ordem das mensagens
// Usar created_at da sessão como base e adicionar segundos baseados no índice
// Isso garante que a ordem seja mantida quando ordenado por created_at ASC
let base_time = session.created_at;
let msg_created_at = base_time + chrono::Duration::seconds(idx as i64);
db::ChatMessage {
id: None,
session_id: session.id.clone(),
role: msg.role.clone(),
content: msg.content.clone(),
metadata: metadata_str,
created_at: msg_created_at,
}
}).collect();
if let Err(e) = db.save_messages_batch(&session.id, &chat_messages) {
log::warn!("Failed to save messages to SQLite (continuing with JSON only): {}", e);
} else {
log::debug!("Successfully saved {} messages to SQLite for session {}", chat_messages.len(), session.id);
}
}
}
Err(e) => {
log::debug!("Failed to open database for saving (JSON saved successfully): {}", e);
}
}
// Lock é liberado automaticamente quando _guard sai de escopo // Lock é liberado automaticamente quando _guard sai de escopo
Ok(()) Ok(())
} }
@@ -407,7 +461,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
.map_err(|e| format!("Failed to open database: {}", e))?; .map_err(|e| format!("Failed to open database: {}", e))?;
let search_limit = limit.unwrap_or(50); let search_limit = limit.unwrap_or(50);
let sessions = db.search_sessions(&query, search_limit) let search_results = db.search_sessions(&query, search_limit)
.map_err(|e| format!("Search failed: {}", e))?; .map_err(|e| format!("Search failed: {}", e))?;
// Validar existência de cada sessão antes de retornar // Validar existência de cada sessão antes de retornar
@@ -415,7 +469,9 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
let mut summaries = Vec::new(); let mut summaries = Vec::new();
let mut orphan_count = 0; let mut orphan_count = 0;
for session in sessions { for search_result in search_results {
let session = search_result.session;
let match_count = search_result.match_count;
// Verificar se sessão existe no SQLite (já temos) // Verificar se sessão existe no SQLite (já temos)
let exists_in_sqlite = db.get_session(&session.id) let exists_in_sqlite = db.get_session(&session.id)
.ok() .ok()
@@ -465,6 +521,7 @@ fn search_chat_sessions(app_handle: AppHandle, query: String, limit: Option<usiz
updated_at: session.updated_at, // Já é DateTime<Utc> updated_at: session.updated_at, // Já é DateTime<Utc>
preview, preview,
platform: String::new(), // Platform não está no SQLite ainda platform: String::new(), // Platform não está no SQLite ainda
match_count: Some(match_count as usize),
}); });
} }
@@ -514,6 +571,7 @@ fn load_chat_sessions(app_handle: AppHandle) -> Result<Vec<SessionSummary>, Stri
updated_at: session.updated_at, updated_at: session.updated_at,
preview: last_msg, preview: last_msg,
platform: session.platform, platform: session.platform,
match_count: None,
}); });
} }
} }
@@ -600,6 +658,299 @@ fn load_chat_history(app_handle: AppHandle, id: String) -> Result<Vec<Message>,
Ok(session.messages) Ok(session.messages)
} }
/// Resultado de carregamento paginado de histórico
#[derive(serde::Serialize)]
struct PaginatedHistory {
messages: Vec<Message>,
total_count: usize,
has_more: bool,
}
/// Carrega histórico de chat com paginação (lazy loading)
///
/// Parâmetros:
/// - id: ID da sessão
/// - limit: número máximo de mensagens a retornar (default: 20)
/// - offset: número de mensagens a pular do final (default: 0)
#[command]
fn load_chat_history_paginated(
app_handle: AppHandle,
id: String,
limit: Option<usize>,
offset: Option<usize>,
) -> Result<PaginatedHistory, String> {
use db::Database;
let limit = limit.unwrap_or(20);
let offset = offset.unwrap_or(0);
match Database::new(&app_handle) {
Ok(db) => {
match db.get_messages_paginated(&id, limit, offset) {
Ok((messages, total_count, has_more)) => {
// Se SQLite retornou 0 mensagens, tentar fallback para JSON (sistema legado)
if total_count == 0 {
log::debug!("No messages in SQLite for session {}, trying JSON fallback", id);
// Tentar carregar do JSON
let chats_dir = match get_chats_dir(&app_handle) {
Ok(dir) => dir,
Err(e) => {
log::debug!("Failed to get chats dir for JSON fallback: {}", e);
// Retornar vazio se não conseguir acessar diretório
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
};
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(content) => {
match serde_json::from_str::<ChatSession>(&content) {
Ok(session) => {
let all_messages = session.messages;
let total = all_messages.len();
if total == 0 {
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
// Aplicar paginação em memória (simular comportamento do SQLite)
// offset=0 significa últimas N mensagens
// offset>0 significa mensagens antes das últimas N
let start_idx = if offset + limit <= total {
total - offset - limit
} else {
0
};
let end_idx = std::cmp::min(start_idx + limit, total);
let paginated_messages: Vec<Message> = all_messages
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
let has_more = offset + paginated_messages.len() < total;
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback for session {}",
paginated_messages.len(), offset, total, has_more, id
);
return Ok(PaginatedHistory {
messages: paginated_messages,
total_count: total,
has_more,
});
}
Err(e) => {
log::debug!("Failed to parse JSON session: {}", e);
}
}
}
Err(e) => {
log::debug!("Failed to read JSON file: {}", e);
}
}
}
// Se chegou aqui, não encontrou mensagens nem no SQLite nem no JSON
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
// Converter ChatMessage para Message
let result: Vec<Message> = messages.into_iter().map(|msg| {
let role = if msg.role == "user" {
"user"
} else if msg.role == "assistant" {
"assistant"
} else {
"system"
};
let metadata = msg.metadata.and_then(|m| {
serde_json::from_str::<serde_json::Value>(&m).ok()
});
let metadata_value = metadata
.and_then(|m| {
if m.is_object() && !m.as_object().unwrap().is_empty() {
Some(m)
} else {
None
}
});
Message {
role: role.to_string(),
content: msg.content,
metadata: metadata_value,
}
}).collect();
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from SQLite for session {}",
result.len(), offset, total_count, has_more, id
);
Ok(PaginatedHistory {
messages: result,
total_count,
has_more,
})
}
Err(e) => {
log::debug!("SQLite query failed for session {}: {}, trying JSON fallback", id, e);
// Fallback para JSON em caso de erro também
let chats_dir = match get_chats_dir(&app_handle) {
Ok(dir) => dir,
Err(_) => {
return Err(format!("Failed to load paginated history: {}", e));
}
};
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(content) => {
match serde_json::from_str::<ChatSession>(&content) {
Ok(session) => {
let all_messages = session.messages;
let total = all_messages.len();
if total == 0 {
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
let start_idx = if offset + limit <= total {
total - offset - limit
} else {
0
};
let end_idx = std::cmp::min(start_idx + limit, total);
let paginated_messages: Vec<Message> = all_messages
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
let has_more = offset + paginated_messages.len() < total;
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (error case) for session {}",
paginated_messages.len(), offset, total, has_more, id
);
return Ok(PaginatedHistory {
messages: paginated_messages,
total_count: total,
has_more,
});
}
Err(e2) => {
return Err(format!("Failed to load paginated history: {} (JSON parse error: {})", e, e2));
}
}
}
Err(e2) => {
return Err(format!("Failed to load paginated history: {} (JSON read error: {})", e, e2));
}
}
}
Err(format!("Failed to load paginated history: {}", e))
}
}
}
Err(e) => {
log::debug!("Failed to open database: {}, trying JSON fallback", e);
// Fallback para JSON se não conseguir abrir banco
let chats_dir = match get_chats_dir(&app_handle) {
Ok(dir) => dir,
Err(e2) => {
return Err(format!("Failed to open database: {} (chats dir error: {})", e, e2));
}
};
let file_path = chats_dir.join(format!("{}.json", id));
if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(content) => {
match serde_json::from_str::<ChatSession>(&content) {
Ok(session) => {
let all_messages = session.messages;
let total = all_messages.len();
if total == 0 {
return Ok(PaginatedHistory {
messages: Vec::new(),
total_count: 0,
has_more: false,
});
}
let start_idx = if offset + limit <= total {
total - offset - limit
} else {
0
};
let end_idx = std::cmp::min(start_idx + limit, total);
let paginated_messages: Vec<Message> = all_messages
.into_iter()
.skip(start_idx)
.take(end_idx - start_idx)
.collect();
let has_more = offset + paginated_messages.len() < total;
log::info!(
"Loaded {} messages (offset: {}, total: {}, has_more: {}) from JSON fallback (db open error) for session {}",
paginated_messages.len(), offset, total, has_more, id
);
return Ok(PaginatedHistory {
messages: paginated_messages,
total_count: total,
has_more,
});
}
Err(e2) => {
return Err(format!("Failed to open database: {} (JSON parse error: {})", e, e2));
}
}
}
Err(e2) => {
return Err(format!("Failed to open database: {} (JSON read error: {})", e, e2));
}
}
}
Err(format!("Failed to open database: {}", e))
}
}
}
#[command] #[command]
fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> { fn delete_chat_session(app_handle: AppHandle, id: String) -> Result<(), String> {
use db::Database; use db::Database;
@@ -2978,12 +3329,18 @@ async fn chat_stream(
return Err(error_msg); return Err(error_msg);
} }
// 5. Processar stream e emitir tokens // 5. Processar stream e emitir tokens COM BUFFERING
// IMPORTANTE: O Ollama envia tokens INCREMENTAIS (cada chunk contém apenas o novo conteúdo) // OTIMIZAÇÃO: Acumular tokens e emitir em batches para reduzir overhead da bridge
let mut stream = response.bytes_stream(); let mut stream = response.bytes_stream();
let mut buffer = String::new(); let mut buffer = String::new();
let mut full_content = String::new(); let mut full_content = String::new();
// Buffer de tokens para reduzir eventos na bridge
let mut token_buffer = String::new();
let mut last_emit = std::time::Instant::now();
const EMIT_INTERVAL_MS: u64 = 16; // ~60fps para sincronizar com RAF do frontend
const MAX_BUFFER_CHARS: usize = 50; // Emitir quando buffer tiver ~50 chars
while let Some(chunk_result) = stream.next().await { while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?; let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?;
let chunk_str = String::from_utf8_lossy(&chunk); let chunk_str = String::from_utf8_lossy(&chunk);
@@ -3007,20 +3364,23 @@ async fn chat_stream(
// Extrair conteúdo do chunk (Ollama envia tokens incrementais) // Extrair conteúdo do chunk (Ollama envia tokens incrementais)
if let Some(message) = json.get("message") { if let Some(message) = json.get("message") {
if let Some(content) = message.get("content").and_then(|c| c.as_str()) { if let Some(content) = message.get("content").and_then(|c| c.as_str()) {
// O Ollama envia apenas o NOVO conteúdo em cada chunk, não o acumulado
// Então podemos emitir diretamente
if !content.is_empty() { if !content.is_empty() {
full_content.push_str(content); full_content.push_str(content);
token_buffer.push_str(content);
// Emitir token para frontend // Emitir buffer quando: tempo >= 16ms OU buffer >= 50 chars
let token_event = ChatTokenEvent { let elapsed = last_emit.elapsed().as_millis() as u64;
session_id: session_id.clone(), if elapsed >= EMIT_INTERVAL_MS || token_buffer.len() >= MAX_BUFFER_CHARS {
content: content.to_string(), let token_event = ChatTokenEvent {
done: false, session_id: session_id.clone(),
}; content: std::mem::take(&mut token_buffer),
done: false,
};
if let Err(e) = window.emit("chat-token", &token_event) { if let Err(e) = window.emit("chat-token", &token_event) {
log::warn!("Erro ao emitir token: {}", e); log::warn!("Erro ao emitir token: {}", e);
}
last_emit = std::time::Instant::now();
} }
} }
} }
@@ -3028,6 +3388,16 @@ async fn chat_stream(
// Verificar se stream terminou // Verificar se stream terminou
if is_done { if is_done {
// Flush do buffer residual antes de finalizar
if !token_buffer.is_empty() {
let flush_event = ChatTokenEvent {
session_id: session_id.clone(),
content: std::mem::take(&mut token_buffer),
done: false,
};
let _ = window.emit("chat-token", &flush_event);
}
// Emitir evento final // Emitir evento final
let final_event = ChatTokenEvent { let final_event = ChatTokenEvent {
session_id: session_id.clone(), session_id: session_id.clone(),
@@ -3121,6 +3491,113 @@ async fn chat_stream(
Ok(session_id) Ok(session_id)
} }
// ============== COMANDOS DE EMBEDDINGS ==============
/// Baixa o modelo de embeddings se não existir
#[command]
async fn download_embedding_model(app_handle: AppHandle) -> Result<bool, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
match embeddings::ensure_model_files(&app_data_dir).await {
Ok(_) => {
log::info!("[Embeddings] Model files ready");
Ok(true)
}
Err(e) => {
log::error!("[Embeddings] Failed to ensure model files: {}", e);
Err(format!("Failed to download model: {}", e))
}
}
}
/// Verifica se o modelo de embeddings está disponível
#[command]
fn is_embedding_model_available(app_handle: AppHandle) -> Result<bool, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
Ok(embeddings::is_model_available(&app_data_dir))
}
/// Calcula scores de relevância para textos em relação a uma query
#[command]
fn calculate_relevance_scores(
app_handle: AppHandle,
query: String,
texts: Vec<String>,
) -> Result<Vec<(usize, f32)>, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let model_arc = embeddings::get_or_init_model(&app_data_dir)
.map_err(|e| format!("Failed to load model: {}", e))?;
let mut model = model_arc.lock()
.map_err(|e| format!("Failed to lock model: {}", e))?;
let text_refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
embeddings::rank_by_relevance(&mut model, &query, &text_refs)
.map_err(|e| format!("Failed to calculate relevance: {}", e))
}
/// Gera embedding para um texto
#[command]
fn generate_embedding(
app_handle: AppHandle,
text: String,
) -> Result<Vec<f32>, String> {
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
let model_arc = embeddings::get_or_init_model(&app_data_dir)
.map_err(|e| format!("Failed to load model: {}", e))?;
let mut model = model_arc.lock()
.map_err(|e| format!("Failed to lock model: {}", e))?;
model.embed(&text)
.map_err(|e| format!("Failed to generate embedding: {}", e))
}
/// Poda o contexto mantendo apenas os parágrafos mais relevantes
#[command]
fn prune_context(
app_handle: AppHandle,
query: String,
context: String,
max_tokens: Option<usize>,
min_score: Option<f32>,
) -> Result<String, String> {
let max_tokens = max_tokens.unwrap_or(2000);
let min_score = min_score.unwrap_or(0.3);
let app_data_dir = app_handle.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
// Tentar usar embeddings se modelo disponível
if embeddings::is_model_available(&app_data_dir) {
let model_arc = embeddings::get_or_init_model(&app_data_dir)
.map_err(|e| format!("Failed to load model: {}", e))?;
let mut model = model_arc.lock()
.map_err(|e| format!("Failed to lock model: {}", e))?;
embeddings::prune_context(&mut model, &query, &context, max_tokens, min_score)
.map_err(|e| format!("Failed to prune context: {}", e))
} else {
// Fallback para BM25-like
log::info!("[PruneContext] Using BM25 fallback (embedding model not available)");
Ok(embeddings::prune_context_bm25(&query, &context, max_tokens))
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -3136,6 +3613,9 @@ pub fn run() {
// Plugin de notificações // Plugin de notificações
app.handle().plugin(tauri_plugin_notification::init())?; app.handle().plugin(tauri_plugin_notification::init())?;
// Plugin de atualização automática
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
// Modificar comportamento de fechar janela (ocultar ao invés de fechar) // Modificar comportamento de fechar janela (ocultar ao invés de fechar)
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let window_clone = window.clone(); let window_clone = window.clone();
@@ -3219,6 +3699,7 @@ pub fn run() {
load_chat_sessions, load_chat_sessions,
search_chat_sessions, search_chat_sessions,
load_chat_history, load_chat_history,
load_chat_history_paginated,
delete_chat_session, delete_chat_session,
cleanup_orphan_sessions, cleanup_orphan_sessions,
load_mcp_config, load_mcp_config,
@@ -3261,7 +3742,13 @@ pub fn run() {
get_downloaded_installer_path, get_downloaded_installer_path,
check_ollama_full, check_ollama_full,
auto_start_ollama, auto_start_ollama,
classify_intent classify_intent,
// Embeddings commands
download_embedding_model,
is_embedding_model_available,
calculate_relevance_scores,
generate_embedding,
prune_context
]) ])
.manage(Arc::new(Mutex::new(HashMap::<String, McpProcessHandle>::new())) as McpProcessMap) .manage(Arc::new(Mutex::new(HashMap::<String, McpProcessHandle>::new())) as McpProcessMap)
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+5 -6
View File
@@ -85,14 +85,13 @@ pub async fn reload_scheduled_tasks(
return; return;
} }
// Obter browser - precisa acessar via app_handle // Obter browser - usando lazy initialization global
let browser_arc = { let browser_arc = {
// Criar browser diretamente se necessário use crate::web_scraper::get_or_create_browser;
use crate::web_scraper::create_browser; match get_or_create_browser() {
match create_browser() { Ok(b) => b,
Ok(b) => Arc::new(b),
Err(e) => { Err(e) => {
log::error!("Erro ao criar browser para task {}: {}", task_id, e); log::error!("Erro ao obter browser para task {}: {}", task_id, e);
return; return;
} }
} }
+102 -3
View File
@@ -2,7 +2,7 @@ use anyhow::Result;
use headless_chrome::{Browser, LaunchOptions, Tab}; use headless_chrome::{Browser, LaunchOptions, Tab};
use reqwest::header::USER_AGENT; use reqwest::header::USER_AGENT;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use std::sync::Arc; use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use url::Url; use url::Url;
use rand::Rng; use rand::Rng;
@@ -10,6 +10,37 @@ use tokio::sync::Semaphore;
use regex::Regex; use regex::Regex;
use std::time::Instant; use std::time::Instant;
/// Lazy-initialized global browser instance
/// Evita criar o browser no startup, economizando ~500MB de RAM até ser necessário
static LAZY_BROWSER: OnceLock<Mutex<Option<Arc<Browser>>>> = OnceLock::new();
/// Obtém ou cria a instância global do browser (lazy initialization)
pub fn get_or_create_browser() -> Result<Arc<Browser>> {
let mutex = LAZY_BROWSER.get_or_init(|| Mutex::new(None));
let mut guard = mutex.lock().map_err(|e| anyhow::anyhow!("Browser mutex poisoned: {}", e))?;
if guard.is_none() {
log::info!("[LazyBrowser] Initializing headless browser on first use...");
let browser = create_browser()?;
*guard = Some(Arc::new(browser));
log::info!("[LazyBrowser] Browser initialized successfully");
}
Ok(guard.as_ref().unwrap().clone())
}
/// Limpa a instância do browser (para liberar memória quando não em uso)
pub fn clear_browser() {
if let Some(mutex) = LAZY_BROWSER.get() {
if let Ok(mut guard) = mutex.lock() {
if guard.is_some() {
log::info!("[LazyBrowser] Clearing browser instance to free memory");
*guard = None;
}
}
}
}
/// Resultado da extração de conteúdo de uma URL /// Resultado da extração de conteúdo de uma URL
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] #[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
pub struct ScrapedContent { pub struct ScrapedContent {
@@ -1302,7 +1333,7 @@ pub async fn search_and_scrape_with_config(
if connection_closed && !failed_urls.is_empty() { if connection_closed && !failed_urls.is_empty() {
let retry_concurrency = std::cmp::min(3, config.max_concurrent_tabs.max(1)); let retry_concurrency = std::cmp::min(3, config.max_concurrent_tabs.max(1));
let semaphore = Arc::new(Semaphore::new(retry_concurrency)); let semaphore = Arc::new(Semaphore::new(retry_concurrency));
let browser_new = Arc::new(create_browser()?); let browser_new = get_or_create_browser()?;
let mut retry_handles = Vec::new(); let mut retry_handles = Vec::new();
for url in failed_urls.clone() { for url in failed_urls.clone() {
let browser_clone = browser_new.clone(); let browser_clone = browser_new.clone();
@@ -1341,11 +1372,79 @@ pub async fn search_and_scrape_with_config(
Ok(results) Ok(results)
} }
/// Busca e extrai conteúdo de uma única URL /// Scraping estático usando apenas reqwest (sem headless browser)
/// Muito mais rápido (~100ms vs ~3s) e consome menos RAM
/// Retorna None se o conteúdo for insuficiente (SPA/JavaScript-heavy)
pub async fn scrape_url_static(url: &str) -> Result<Option<ScrapedContent>> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.redirect(reqwest::redirect::Policy::limited(5))
.build()?;
let user_agent = get_random_user_agent();
let start_time = Instant::now();
log::debug!("[StaticScrape] Fetching: {}", url);
let response = match client
.get(url)
.header(USER_AGENT, user_agent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7")
.send()
.await
{
Ok(r) => r,
Err(e) => {
log::debug!("[StaticScrape] Request failed for {}: {}", url, e);
return Ok(None);
}
};
if !response.status().is_success() {
log::debug!("[StaticScrape] HTTP {} for {}", response.status(), url);
return Ok(None);
}
let html = match response.text().await {
Ok(t) => t,
Err(e) => {
log::debug!("[StaticScrape] Failed to read body for {}: {}", url, e);
return Ok(None);
}
};
let duration = start_time.elapsed().as_millis();
// Usar extract_paragraph_fallback existente
let result = extract_paragraph_fallback(url, &html);
if let Some(ref content) = result {
log::info!("[StaticScrape] Success for {} ({} chars, {}ms)",
url, content.content.len(), duration);
} else {
log::debug!("[StaticScrape] Insufficient content for {} ({}ms)", url, duration);
}
Ok(result)
}
/// Busca e extrai conteúdo de uma única URL (híbrido: tenta estático primeiro)
pub async fn scrape_url( pub async fn scrape_url(
url: &str, url: &str,
browser: Arc<Browser>, browser: Arc<Browser>,
) -> Result<ScrapedContent> { ) -> Result<ScrapedContent> {
// OTIMIZAÇÃO: Tentar scraping estático primeiro (muito mais rápido)
if let Ok(Some(content)) = scrape_url_static(url).await {
// Se conseguiu conteúdo suficiente (>500 chars), usar resultado estático
if content.content.len() > 500 {
log::info!("[ScrapeHybrid] Using static result for {} ({} chars)", url, content.content.len());
return Ok(content);
}
}
// Fallback: usar headless browser para SPAs/JS-heavy pages
log::info!("[ScrapeHybrid] Falling back to headless for {}", url);
let browser_clone = browser.clone(); let browser_clone = browser.clone();
let url_str = url.to_string(); let url_str = url.to_string();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
+11 -1
View File
@@ -7,7 +7,7 @@
"frontendDist": "../out", "frontendDist": "../out",
"devUrl": "http://localhost:3000", "devUrl": "http://localhost:3000",
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build" "beforeBuildCommand": "pnpm generate-changelog && pnpm build"
}, },
"app": { "app": {
"windows": [ "windows": [
@@ -35,5 +35,15 @@
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ]
},
"plugins": {
"updater": {
"active": true,
"endpoints": [
"https://github.com/evandrodevbr/OllaHub/releases/latest/download/latest.json"
],
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxMjY3NThDMUQyREQ5QUYKUldTdjJTMGRqSFVtZ1k1ZWFHSEcxNExDZzgrNVh6M0Z6VXIyaTU5dDAvZnpQb3p0UVpuRW1Od3YK"
}
} }
} }
+22 -1
View File
@@ -56,6 +56,9 @@ export interface SettingsState {
keywords: string[]; keywords: string[];
}; };
// Updates
autoCheckUpdates: boolean;
// Query Preprocessing // Query Preprocessing
queryPreprocessing: { queryPreprocessing: {
enabled: boolean; enabled: boolean;
@@ -96,6 +99,9 @@ export interface SettingsState {
addNotificationKeyword: (keyword: string) => void; addNotificationKeyword: (keyword: string) => void;
removeNotificationKeyword: (keyword: string) => void; removeNotificationKeyword: (keyword: string) => void;
// Updates Actions
setAutoCheckUpdates: (enabled: boolean) => void;
// Query Preprocessing Actions // Query Preprocessing Actions
setQueryPreprocessingEnabled: (enabled: boolean) => void; setQueryPreprocessingEnabled: (enabled: boolean) => void;
setQueryPreprocessingMinLength: (min: number) => void; setQueryPreprocessingMinLength: (min: number) => void;
@@ -221,6 +227,7 @@ const initialState = {
enabled: true, enabled: true,
keywords: [], keywords: [],
}, },
autoCheckUpdates: true,
queryPreprocessing: { queryPreprocessing: {
enabled: true, enabled: true,
minLength: 3, minLength: 3,
@@ -407,6 +414,10 @@ export const useSettingsStore = create<SettingsState>()(
}, },
})), })),
// Updates Actions
setAutoCheckUpdates: (enabled) =>
set({ autoCheckUpdates: enabled }),
// Query Preprocessing Actions // Query Preprocessing Actions
setQueryPreprocessingEnabled: (enabled) => setQueryPreprocessingEnabled: (enabled) =>
set((state) => ({ set((state) => ({
@@ -479,7 +490,17 @@ export const useSettingsStore = create<SettingsState>()(
}), }),
{ {
name: 'ollahub-settings', name: 'ollahub-settings',
version: 1, version: 3,
migrate: (persistedState: any, version: number) => {
// Migração da versão 1 para 3
if (version < 3) {
return {
...persistedState,
autoCheckUpdates: persistedState.autoCheckUpdates ?? true,
};
}
return persistedState;
},
} }
) )
); );
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxMjY3NThDMUQyREQ5QUYKUldTdjJTMGRqSFVtZ1k1ZWFHSEcxNExDZzgrNVh6M0Z6VXIyaTU5dDAvZnpQb3p0UVpuRW1Od3YK