Compare commits
No files matched your search
@@ -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
|
||||||
|
|
||||||
@@ -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?
|
|
||||||
@@ -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:
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
[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>
|
||||||
+238
-6
@@ -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,7 +881,93 @@ 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();
|
||||||
@@ -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,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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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' &&
|
||||||
@@ -51,6 +61,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
|
||||||
@@ -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';
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
@@ -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
|
||||||
@@ -40,6 +63,17 @@ export function useChatStorage() {
|
|||||||
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
||||||
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 {
|
||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+10
@@ -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
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- '.'
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- sharp
|
- sharp
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
@@ -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}`);
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Generated
+679
-15
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+258
-61
@@ -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 rows = stmt.query_map(params![&fts_query, limit], |row| {
|
||||||
|
Ok(SearchSessionResult {
|
||||||
let mut sessions = Vec::new();
|
session: ChatSession {
|
||||||
for row in rows {
|
id: row.get(0)?,
|
||||||
sessions.push(row?);
|
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)?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+503
-16
@@ -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) {
|
};
|
||||||
log::warn!("Erro ao emitir token: {}", e);
|
|
||||||
|
if let Err(e) = window.emit("chat-token", &token_event) {
|
||||||
|
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!())
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxMjY3NThDMUQyREQ5QUYKUldTdjJTMGRqSFVtZ1k1ZWFHSEcxNExDZzgrNVh6M0Z6VXIyaTU5dDAvZnpQb3p0UVpuRW1Od3YK
|
||||||
Reference in new issue
Block a user