Merge pull request #5 from evandrodevbr:develop
remove: delete the document "Estabilizar Scraper Rust (Browser Resurrection, Send e Retry)" as it is no longer needed for the project. This document contained analysis, context, and implementation plans for improving the scraper's resilience and functionality.
This commit is contained in:
commit
1daa6c77ee
134 files changed
+41134
-402
No files matched your search
@@ -0,0 +1,185 @@
|
|||||||
|
name: Build and Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
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: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- 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: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- 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
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# Guia de Implementação: System Tray, Background Mode e Task Scheduler
|
||||||
|
|
||||||
|
## Status da Implementação
|
||||||
|
|
||||||
|
### ✅ Concluído
|
||||||
|
|
||||||
|
1. **Estrutura de Tasks** (`src-tauri/src/scheduler.rs`)
|
||||||
|
- `SentinelTask` com suporte a múltiplos tipos de ações
|
||||||
|
- Persistência em `tasks.json`
|
||||||
|
- Gerenciamento de estado thread-safe
|
||||||
|
|
||||||
|
2. **Cliente Ollama Headless** (`src-tauri/src/ollama_client.rs`)
|
||||||
|
- Comunicação direta com Ollama via `reqwest`
|
||||||
|
- Suporte a streaming
|
||||||
|
- Verificação de conexão
|
||||||
|
|
||||||
|
3. **Executor de Tasks** (`src-tauri/src/task_executor.rs`)
|
||||||
|
- Execução de `SearchAndSummarize`
|
||||||
|
- Execução de `JustPing`
|
||||||
|
- Execução de `CustomPrompt`
|
||||||
|
- Integração com file locks para escrita segura
|
||||||
|
|
||||||
|
4. **Scheduler Loop** (`src-tauri/src/scheduler_loop.rs`)
|
||||||
|
- Integração com `tokio-cron-scheduler`
|
||||||
|
- Recarregamento dinâmico de tasks
|
||||||
|
- Execução assíncrona em background
|
||||||
|
|
||||||
|
### 🔨 Pendente (Próximos Passos)
|
||||||
|
|
||||||
|
#### 1. Comandos Tauri para CRUD de Tasks
|
||||||
|
|
||||||
|
Adicione ao `src-tauri/src/lib.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use scheduler::{SchedulerService, SchedulerState, SentinelTask, TaskAction};
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
async fn create_task(
|
||||||
|
scheduler: State<'_, SchedulerState>,
|
||||||
|
label: String,
|
||||||
|
cron_schedule: String,
|
||||||
|
action: TaskAction,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let task = SentinelTask {
|
||||||
|
id: Uuid::new_v4().to_string(),
|
||||||
|
label,
|
||||||
|
cron_schedule,
|
||||||
|
action,
|
||||||
|
enabled: true,
|
||||||
|
last_run: None,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut sched = scheduler.lock().await;
|
||||||
|
sched.upsert_task(task.clone())?;
|
||||||
|
Ok(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
async fn list_tasks(
|
||||||
|
scheduler: State<'_, SchedulerState>,
|
||||||
|
) -> Result<Vec<SentinelTask>, String> {
|
||||||
|
let sched = scheduler.lock().await;
|
||||||
|
Ok(sched.list_tasks())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
async fn update_task(
|
||||||
|
scheduler: State<'_, SchedulerState>,
|
||||||
|
task: SentinelTask,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut sched = scheduler.lock().await;
|
||||||
|
let mut updated = task;
|
||||||
|
updated.updated_at = Utc::now();
|
||||||
|
sched.upsert_task(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
async fn delete_task(
|
||||||
|
scheduler: State<'_, SchedulerState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut sched = scheduler.lock().await;
|
||||||
|
sched.remove_task(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
async fn toggle_task(
|
||||||
|
scheduler: State<'_, SchedulerState>,
|
||||||
|
id: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut sched = scheduler.lock().await;
|
||||||
|
if let Some(mut task) = sched.get_task(&id).cloned() {
|
||||||
|
task.enabled = enabled;
|
||||||
|
task.updated_at = Utc::now();
|
||||||
|
sched.upsert_task(task)
|
||||||
|
} else {
|
||||||
|
Err("Task not found".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. System Tray Setup
|
||||||
|
|
||||||
|
No `src-tauri/src/lib.rs`, modifique o `.setup()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tauri::{Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem};
|
||||||
|
|
||||||
|
.setup(|app| {
|
||||||
|
// ... código existente ...
|
||||||
|
|
||||||
|
// Criar System Tray
|
||||||
|
let tray_menu = SystemTrayMenu::new()
|
||||||
|
.add_item(SystemTrayMenuItem::with_id("show", "Mostrar OllaHub", true, None))
|
||||||
|
.add_item(SystemTrayMenuItem::with_id("pause", "Pausar Tasks", true, None))
|
||||||
|
.add_native_item(SystemTrayMenuItem::Separator)
|
||||||
|
.add_item(SystemTrayMenuItem::with_id("quit", "Sair", true, None));
|
||||||
|
|
||||||
|
let system_tray = SystemTray::new().with_menu(tray_menu);
|
||||||
|
|
||||||
|
app.handle().system_tray(system_tray)?;
|
||||||
|
|
||||||
|
// Handler de eventos do Tray
|
||||||
|
app.handle().on_system_tray_event(|app, event| {
|
||||||
|
if let SystemTrayEvent::MenuItemClick { id, .. } = event {
|
||||||
|
match id.as_str() {
|
||||||
|
"show" => {
|
||||||
|
if let Some(window) = app.get_window("main") {
|
||||||
|
window.show().unwrap();
|
||||||
|
window.set_focus().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"pause" => {
|
||||||
|
// Implementar lógica de pausar tasks
|
||||||
|
log::info!("Pausar tasks - implementar");
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modificar comportamento de fechar janela
|
||||||
|
if let Some(window) = app.get_window("main") {
|
||||||
|
window.on_window_event(|event| {
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
// Ocultar ao invés de fechar
|
||||||
|
if let Some(window) = event.window() {
|
||||||
|
window.hide().unwrap();
|
||||||
|
}
|
||||||
|
api.prevent_close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Inicializar Scheduler no Setup
|
||||||
|
|
||||||
|
No mesmo `.setup()`, após criar o scheduler:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Inicializar scheduler
|
||||||
|
let scheduler_service = SchedulerService::new(app.handle().clone())?;
|
||||||
|
let scheduler_state: SchedulerState = Arc::new(Mutex::new(scheduler_service));
|
||||||
|
let browser_state: BrowserState = app.state();
|
||||||
|
|
||||||
|
// Iniciar loop do scheduler em background
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
let scheduler_clone = scheduler_state.clone();
|
||||||
|
let browser_clone = browser_state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = scheduler_loop::start_scheduler_loop(
|
||||||
|
app_handle,
|
||||||
|
scheduler_clone,
|
||||||
|
browser_clone,
|
||||||
|
None, // Ollama URL - pode vir do settings
|
||||||
|
).await {
|
||||||
|
log::error!("Erro ao iniciar scheduler: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adicionar ao manage
|
||||||
|
.manage(scheduler_state)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Adicionar Comandos ao Invoke Handler
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
// ... comandos existentes ...
|
||||||
|
create_task,
|
||||||
|
list_tasks,
|
||||||
|
update_task,
|
||||||
|
delete_task,
|
||||||
|
toggle_task,
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Interface React (app/tasks/page.tsx)
|
||||||
|
|
||||||
|
Crie uma página básica para gerenciar tasks:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
cron_schedule: string;
|
||||||
|
action: any;
|
||||||
|
enabled: boolean;
|
||||||
|
last_run: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTasks = async () => {
|
||||||
|
try {
|
||||||
|
const loaded = await invoke<Task[]>('list_tasks');
|
||||||
|
setTasks(loaded);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar tasks:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTask = async (id: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await invoke('toggle_task', { id, enabled: !enabled });
|
||||||
|
await loadTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao alternar task:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Tarefas Agendadas</h1>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tasks.map(task => (
|
||||||
|
<Card key={task.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{task.label}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>Cron: {task.cron_schedule}</p>
|
||||||
|
<p>Status: {task.enabled ? 'Ativa' : 'Pausada'}</p>
|
||||||
|
<Button onClick={() => toggleTask(task.id, task.enabled)}>
|
||||||
|
{task.enabled ? 'Pausar' : 'Ativar'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependências Adicionais Necessárias
|
||||||
|
|
||||||
|
No `Cargo.toml`, já foram adicionadas:
|
||||||
|
- `tokio-cron-scheduler = "0.9"`
|
||||||
|
- `futures-util = "0.3"`
|
||||||
|
- `tauri-plugin-notification = "2"`
|
||||||
|
|
||||||
|
**Nota para Linux:** Para System Tray funcionar, pode ser necessário:
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install libappindicator3-1
|
||||||
|
|
||||||
|
# Arch/Manjaro
|
||||||
|
sudo pacman -S libappindicator-gtk3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Próximos Passos
|
||||||
|
|
||||||
|
1. Compilar e testar: `cd src-tauri && cargo build`
|
||||||
|
2. Corrigir erros de compilação
|
||||||
|
3. Testar criação de task via frontend
|
||||||
|
4. Verificar se scheduler executa corretamente
|
||||||
|
5. Testar System Tray em diferentes plataformas
|
||||||
|
|
||||||
|
## Notas Importantes
|
||||||
|
|
||||||
|
- **File Locking**: Já implementado - tasks usam o mesmo sistema de locks que salvar sessões
|
||||||
|
- **Concorrência**: Scheduler roda em thread separada, não bloqueia UI
|
||||||
|
- **Persistência**: Tasks são salvas em `app_data_dir/tasks.json`
|
||||||
|
- **Notificações**: Usa `tauri-plugin-notification` para notificações nativas
|
||||||
|
|
||||||
+1448
-115
File diff suppressed because it is too large.
Load diff
+284
-1
@@ -2,6 +2,7 @@
|
|||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
@custom-variant hc (&:is([data-high-contrast=true] *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
@@ -37,10 +38,23 @@
|
|||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
--color-message-user: var(--message-user);
|
||||||
|
--color-message-assistant: var(--message-assistant);
|
||||||
|
--color-message-highlight: var(--message-highlight);
|
||||||
|
--color-message-selected: var(--message-selected);
|
||||||
|
--color-message-high-contrast: var(--message-high-contrast);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--space-1: 8px;
|
||||||
|
--space-2: 16px;
|
||||||
|
--space-3: 24px;
|
||||||
|
--space-4: 32px;
|
||||||
|
--space-5: 40px;
|
||||||
|
--space-6: 48px;
|
||||||
|
--space-7: 56px;
|
||||||
|
--space-8: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
@@ -76,6 +90,11 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||||
|
--message-user: oklch(0.965 0.02 245);
|
||||||
|
--message-assistant: oklch(0.972 0.012 300);
|
||||||
|
--message-highlight: oklch(0.94 0.02 250);
|
||||||
|
--message-selected: oklch(0.92 0.02 285);
|
||||||
|
--message-high-contrast: oklch(0.99 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -110,13 +129,277 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||||
|
--message-user: oklch(0.23 0.02 245);
|
||||||
|
--message-assistant: oklch(0.24 0.015 300);
|
||||||
|
--message-highlight: oklch(0.34 0.02 250);
|
||||||
|
--message-selected: oklch(0.25 0.02 285);
|
||||||
|
--message-high-contrast: oklch(0.18 0.005 286);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-high-contrast="true"] {
|
||||||
|
--message-user: oklch(0.9 0 0);
|
||||||
|
--message-assistant: oklch(0.9 0 0);
|
||||||
|
--message-highlight: oklch(0.8 0 0);
|
||||||
|
--message-selected: oklch(0.7 0 0);
|
||||||
|
--message-high-contrast: oklch(0.99 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar Styles - Padronizado para toda interface */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--primary) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--primary)) hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive layout adjustments */
|
||||||
|
@media (max-width: 650px) {
|
||||||
|
/* Sidebar compact mode - hide labels, show only icons */
|
||||||
|
.sidebar-compact .sidebar-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-compact .sidebar-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper spacing and padding at all resolutions */
|
||||||
|
@media (min-width: 320px) and (max-width: 2560px) {
|
||||||
|
/* Prevent layout breaking at extreme sizes */
|
||||||
|
.chat-container {
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container {
|
||||||
|
padding-left: clamp(12px, 4vw, 24px);
|
||||||
|
padding-right: clamp(12px, 4vw, 24px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text overflow in cards and messages */
|
||||||
|
.truncate-with-ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure cards never stick to edges */
|
||||||
|
.card-container {
|
||||||
|
padding: clamp(8px, 2vw, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar chat item - prevent overflow and ensure proper truncation */
|
||||||
|
.sidebar-chat-item {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-item-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.625rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-item-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-item-title {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-item-date {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive font sizes */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.responsive-text {
|
||||||
|
font-size: clamp(14px, 4vw, 16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Model name truncation - ensure long model names don't break layout */
|
||||||
|
.model-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 70vw;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.model-name {
|
||||||
|
max-width: 54vw;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select value truncation */
|
||||||
|
[data-radix-select-trigger] span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure SelectItems also truncate properly */
|
||||||
|
[data-radix-select-item] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-radix-select-item] span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 70vw;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar específico para lista de chats - mais fino e discreto */
|
||||||
|
.sidebar-chat-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-list::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-chat-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--primary) / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar para lista de chats */
|
||||||
|
.sidebar-chat-list {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimalista scrollbar para sources */
|
||||||
|
.sources-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted-foreground) / 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-scrollbar::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.2);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ocultar scrollbar quando não há overflow */
|
||||||
|
.sources-scrollbar:not(:hover)::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reasoning Chain animations */
|
||||||
|
@keyframes reasoning-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reasoning-step {
|
||||||
|
animation: reasoning-fade-in 0.3s ease-out forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered reveal para steps */
|
||||||
|
.reasoning-step:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.reasoning-step:nth-child(2) { animation-delay: 100ms; }
|
||||||
|
.reasoning-step:nth-child(3) { animation-delay: 200ms; }
|
||||||
|
.reasoning-step:nth-child(4) { animation-delay: 300ms; }
|
||||||
|
.reasoning-step:nth-child(5) { animation-delay: 400ms; }
|
||||||
|
.reasoning-step:nth-child(6) { animation-delay: 500ms; }
|
||||||
|
.reasoning-step:nth-child(7) { animation-delay: 600ms; }
|
||||||
|
.reasoning-step:nth-child(8) { animation-delay: 700ms; }
|
||||||
|
.reasoning-step:nth-child(n+9) { animation-delay: 800ms; }
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,13 +4,36 @@ import { useOllamaCheck } from "@/hooks/use-ollama-check";
|
|||||||
import { Hero } from "@/components/landing/hero";
|
import { Hero } from "@/components/landing/hero";
|
||||||
import { InstallModal } from "@/components/modals/install-modal";
|
import { InstallModal } from "@/components/modals/install-modal";
|
||||||
import { StoppedCard } from "@/components/landing/stopped-card";
|
import { StoppedCard } from "@/components/landing/stopped-card";
|
||||||
|
import { WelcomeNotification } from "@/components/notifications/welcome-notification";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { status, check } = useOllamaCheck();
|
const { status, check } = useOllamaCheck();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Tentar iniciar Ollama automaticamente quando o componente montar
|
||||||
|
useEffect(() => {
|
||||||
|
const tryAutoStart = async () => {
|
||||||
|
try {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
|
await invoke('auto_start_ollama');
|
||||||
|
// Aguardar um pouco e verificar novamente
|
||||||
|
setTimeout(() => {
|
||||||
|
check();
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao tentar iniciar Ollama automaticamente:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Só tentar se estiver instalado mas não rodando
|
||||||
|
if (status === 'installed_stopped') {
|
||||||
|
tryAutoStart();
|
||||||
|
}
|
||||||
|
}, [status, check]);
|
||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
const setupComplete = localStorage.getItem("ollahub_setup_complete");
|
const setupComplete = localStorage.getItem("ollahub_setup_complete");
|
||||||
if (setupComplete) {
|
if (setupComplete) {
|
||||||
@@ -31,6 +54,8 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
|
<main className="flex min-h-screen flex-col items-center justify-center p-4 bg-background text-foreground transition-colors duration-300">
|
||||||
|
{/* Notificação de boas-vindas */}
|
||||||
|
<WelcomeNotification />
|
||||||
|
|
||||||
{/* Layout for Running State */}
|
{/* Layout for Running State */}
|
||||||
{status === 'running' && (
|
{status === 'running' && (
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+8
-1
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useHardware } from "@/hooks/use-hardware";
|
import { useHardware } from "@/hooks/use-hardware";
|
||||||
import { HardwareScan } from "@/components/setup/hardware-scan";
|
import { HardwareScan } from "@/components/setup/hardware-scan";
|
||||||
|
import { GpuSelector } from "@/components/setup/gpu-selector";
|
||||||
import { ModelSelector } from "@/components/setup/model-selector";
|
import { ModelSelector } from "@/components/setup/model-selector";
|
||||||
import { getRecommendation } from "@/lib/recommendation";
|
import { getRecommendation } from "@/lib/recommendation";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function SetupPage() {
|
export default function SetupPage() {
|
||||||
const { specs, loading } = useHardware();
|
const { specs, loading, gpus } = useHardware();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
@@ -26,6 +27,12 @@ export default function SetupPage() {
|
|||||||
|
|
||||||
<HardwareScan specs={specs} loading={loading} />
|
<HardwareScan specs={specs} loading={loading} />
|
||||||
|
|
||||||
|
{!loading && specs && gpus.length > 0 && (
|
||||||
|
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-200 fill-mode-backwards">
|
||||||
|
<GpuSelector gpus={gpus} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && specs && (
|
{!loading && specs && (
|
||||||
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-300 fill-mode-backwards">
|
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-300 fill-mode-backwards">
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Plus, Trash2, Clock, Play, Pause, CheckCircle2, XCircle, X, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { TitleBar } from '@/components/titlebar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface Task {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
cron_schedule: string;
|
||||||
|
action: {
|
||||||
|
type: 'search_and_summarize' | 'just_ping' | 'custom_prompt';
|
||||||
|
query?: string;
|
||||||
|
model?: string;
|
||||||
|
max_results?: number;
|
||||||
|
message?: string;
|
||||||
|
prompt?: string;
|
||||||
|
};
|
||||||
|
enabled: boolean;
|
||||||
|
last_run: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTasks = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const loaded = await invoke<Task[]>('list_tasks');
|
||||||
|
setTasks(loaded);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao carregar tasks:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTask = async (id: string, currentEnabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await invoke('toggle_task', { id, enabled: !currentEnabled });
|
||||||
|
await loadTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao alternar task:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTask = async (id: string) => {
|
||||||
|
if (!confirm('Tem certeza que deseja excluir esta task?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('delete_task', { id });
|
||||||
|
await loadTasks();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao excluir task:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCron = (cron: string) => {
|
||||||
|
// Formatação básica de cron para exibição
|
||||||
|
const parts = cron.split(' ');
|
||||||
|
if (parts.length === 5) {
|
||||||
|
const [min, hour, day, month, weekday] = parts;
|
||||||
|
if (min === '0' && hour !== '*' && day === '*' && month === '*' && weekday === '*') {
|
||||||
|
return `Todo dia às ${hour}:00`;
|
||||||
|
}
|
||||||
|
if (min === '0' && hour === '8' && day === '*' && month === '*' && weekday === '*') {
|
||||||
|
return 'Todo dia às 8:00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cron;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLastRun = (lastRun: string | null) => {
|
||||||
|
if (!lastRun) return 'Nunca';
|
||||||
|
try {
|
||||||
|
const date = new Date(lastRun);
|
||||||
|
return date.toLocaleString('pt-BR');
|
||||||
|
} catch {
|
||||||
|
return lastRun;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full bg-background overflow-hidden flex flex-col">
|
||||||
|
<TitleBar />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="container mx-auto p-4 sm:p-6 max-w-6xl w-full overflow-x-hidden">
|
||||||
|
<div className="mb-4 sm:mb-6">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold truncate">Tarefas Agendadas</h1>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm sm:text-base">
|
||||||
|
Gerencie tarefas que executam automaticamente em segundo plano
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push('/chat')}
|
||||||
|
className="rounded-lg shrink-0"
|
||||||
|
title="Voltar para o chat"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
Carregando tasks...
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center">
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Nenhuma task agendada ainda.
|
||||||
|
</p>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="lg" className="w-full max-w-md">
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Criar Primeira Task
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Criar Nova Task</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure uma tarefa para executar automaticamente
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<TaskFormDialog
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
loadTasks();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="lg" className="w-full">
|
||||||
|
<Plus className="w-5 h-5 mr-2" />
|
||||||
|
Nova Task
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Criar Nova Task</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure uma tarefa para executar automaticamente
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<TaskFormDialog
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
loadTasks();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{tasks.map(task => (
|
||||||
|
<Card key={task.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{task.label}
|
||||||
|
{task.enabled ? (
|
||||||
|
<Badge variant="default" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||||
|
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||||
|
Ativa
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<XCircle className="w-3 h-3 mr-1" />
|
||||||
|
Pausada
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-2">
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{formatCron(task.cron_schedule)}
|
||||||
|
</span>
|
||||||
|
<span>Última execução: {formatLastRun(task.last_run)}</span>
|
||||||
|
</div>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={`toggle-${task.id}`} className="text-sm">
|
||||||
|
{task.enabled ? 'Ativa' : 'Pausada'}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id={`toggle-${task.id}`}
|
||||||
|
checked={task.enabled}
|
||||||
|
onCheckedChange={() => toggleTask(task.id, task.enabled)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => deleteTask(task.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p><strong>Tipo:</strong> {
|
||||||
|
task.action.type === 'search_and_summarize' ? 'Pesquisar e Resumir' :
|
||||||
|
task.action.type === 'just_ping' ? 'Notificação' :
|
||||||
|
'Prompt Customizado'
|
||||||
|
}</p>
|
||||||
|
{task.action.query && (
|
||||||
|
<p><strong>Query:</strong> {task.action.query}</p>
|
||||||
|
)}
|
||||||
|
{task.action.model && (
|
||||||
|
<p><strong>Modelo:</strong> {task.action.model}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskFormDialog({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [scheduleType, setScheduleType] = useState<'daily' | 'weekly' | 'hourly' | 'custom'>('daily');
|
||||||
|
const [hour, setHour] = useState('8');
|
||||||
|
const [minute, setMinute] = useState('0');
|
||||||
|
const [intervalHours, setIntervalHours] = useState('1');
|
||||||
|
const [customCron, setCustomCron] = useState('');
|
||||||
|
const [taskType, setTaskType] = useState<'search_and_summarize' | 'just_ping' | 'custom_prompt'>('search_and_summarize');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [model, setModel] = useState('llama3.2');
|
||||||
|
const [maxResults, setMaxResults] = useState(3);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [customPrompt, setCustomPrompt] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const generateCron = (): string => {
|
||||||
|
if (scheduleType === 'custom') {
|
||||||
|
return customCron;
|
||||||
|
}
|
||||||
|
if (scheduleType === 'hourly') {
|
||||||
|
return `0 */${intervalHours} * * *`;
|
||||||
|
}
|
||||||
|
if (scheduleType === 'daily') {
|
||||||
|
return `${minute} ${hour} * * *`;
|
||||||
|
}
|
||||||
|
// Weekly (every Monday at hour:minute)
|
||||||
|
return `${minute} ${hour} * * 1`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCronPreview = (): string => {
|
||||||
|
const cron = generateCron();
|
||||||
|
if (scheduleType === 'hourly') {
|
||||||
|
return `A cada ${intervalHours} hora(s)`;
|
||||||
|
}
|
||||||
|
if (scheduleType === 'daily') {
|
||||||
|
return `Todo dia às ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (scheduleType === 'weekly') {
|
||||||
|
return `Toda segunda-feira às ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return cron;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let action: any;
|
||||||
|
|
||||||
|
if (taskType === 'search_and_summarize') {
|
||||||
|
action = {
|
||||||
|
type: 'search_and_summarize',
|
||||||
|
query,
|
||||||
|
model,
|
||||||
|
max_results: maxResults,
|
||||||
|
};
|
||||||
|
} else if (taskType === 'just_ping') {
|
||||||
|
action = {
|
||||||
|
type: 'just_ping',
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
action = {
|
||||||
|
type: 'custom_prompt',
|
||||||
|
prompt: customPrompt,
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoke('create_task', {
|
||||||
|
label,
|
||||||
|
cronSchedule: generateCron(),
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao criar task:', error);
|
||||||
|
alert('Erro ao criar task. Verifique o console para mais detalhes.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="label">Nome da Task</Label>
|
||||||
|
<Input
|
||||||
|
id="label"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="Ex: Resumo de Notícias Diário"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="schedule-type">Frequência</Label>
|
||||||
|
<Select value={scheduleType} onValueChange={(v: any) => setScheduleType(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="daily">Diário</SelectItem>
|
||||||
|
<SelectItem value="weekly">Semanal</SelectItem>
|
||||||
|
<SelectItem value="hourly">A cada X horas</SelectItem>
|
||||||
|
<SelectItem value="custom">Cron Customizado (Avançado)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cron Preview */}
|
||||||
|
<div className="bg-muted p-3 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Agendamento:</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{getCronPreview()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground font-mono">
|
||||||
|
Cron: {generateCron()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scheduleType === 'custom' ? (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="cron">Expressão Cron</Label>
|
||||||
|
<Input
|
||||||
|
id="cron"
|
||||||
|
value={customCron}
|
||||||
|
onChange={(e) => setCustomCron(e.target.value)}
|
||||||
|
placeholder="0 8 * * * (todo dia às 8h)"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Formato: minuto hora dia mês dia-da-semana (ex: 0 8 * * * = todo dia às 8h)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : scheduleType === 'hourly' ? (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="interval-hours">Intervalo (horas)</Label>
|
||||||
|
<Input
|
||||||
|
id="interval-hours"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="24"
|
||||||
|
value={intervalHours}
|
||||||
|
onChange={(e) => setIntervalHours(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
A task será executada a cada {intervalHours} hora(s)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="hour">Hora (0-23)</Label>
|
||||||
|
<Input
|
||||||
|
id="hour"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="23"
|
||||||
|
value={hour}
|
||||||
|
onChange={(e) => setHour(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="minute">Minuto (0-59)</Label>
|
||||||
|
<Input
|
||||||
|
id="minute"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="59"
|
||||||
|
value={minute}
|
||||||
|
onChange={(e) => setMinute(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="task-type">Tipo de Task</Label>
|
||||||
|
<Select value={taskType} onValueChange={(v: any) => setTaskType(v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="search_and_summarize">Pesquisar e Resumir</SelectItem>
|
||||||
|
<SelectItem value="just_ping">Apenas Notificação</SelectItem>
|
||||||
|
<SelectItem value="custom_prompt">Prompt Customizado</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{taskType === 'search_and_summarize' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="query">Query de Pesquisa</Label>
|
||||||
|
<Input
|
||||||
|
id="query"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Ex: notícias sobre inteligência artificial hoje"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="model">Modelo</Label>
|
||||||
|
<Input
|
||||||
|
id="model"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="llama3.2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="max-results">Máx. Resultados</Label>
|
||||||
|
<Input
|
||||||
|
id="max-results"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={maxResults}
|
||||||
|
onChange={(e) => setMaxResults(parseInt(e.target.value))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{taskType === 'just_ping' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="message">Mensagem</Label>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder="Mensagem da notificação"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{taskType === 'custom_prompt' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="prompt">Prompt</Label>
|
||||||
|
<Textarea
|
||||||
|
id="prompt"
|
||||||
|
value={customPrompt}
|
||||||
|
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||||
|
placeholder="Seu prompt customizado aqui"
|
||||||
|
required
|
||||||
|
className="min-h-[100px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="model-prompt">Modelo</Label>
|
||||||
|
<Input
|
||||||
|
id="model-prompt"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder="llama3.2"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? 'Criando...' : 'Criar Task'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
+207
-18
@@ -1,17 +1,83 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Send, Square } from "lucide-react";
|
import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle, GraduationCap, Newspaper, Terminal, TrendingUp } from "lucide-react";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { quickValidate, validateQuery } from "@/lib/query-validation";
|
||||||
|
import { useSettingsStore, type SearchCategory } from "@/store/settings-store";
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
webSearchEnabled?: boolean;
|
||||||
|
onWebSearchToggle?: (enabled: boolean) => void;
|
||||||
|
categories?: SearchCategory[];
|
||||||
|
onToggleCategory?: (id: string, enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
export function ChatInput({
|
||||||
|
onSend,
|
||||||
|
onStop,
|
||||||
|
isLoading,
|
||||||
|
webSearchEnabled = true,
|
||||||
|
onWebSearchToggle,
|
||||||
|
categories = [],
|
||||||
|
onToggleCategory,
|
||||||
|
}: ChatInputProps) {
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [webDialogOpen, setWebDialogOpen] = useState(false);
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
|
// Validação em tempo real
|
||||||
|
const validation = useMemo(() => {
|
||||||
|
if (!input.trim()) {
|
||||||
|
return { isValid: true, errors: [], warnings: [] };
|
||||||
|
}
|
||||||
|
const preprocessingConfig = settings.queryPreprocessing || {
|
||||||
|
enabled: true,
|
||||||
|
minLength: 3,
|
||||||
|
maxLength: 2000,
|
||||||
|
autoSplitQuestions: true,
|
||||||
|
irrelevantPatterns: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!preprocessingConfig.enabled) {
|
||||||
|
return { isValid: true, errors: [], warnings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return validateQuery(input, {
|
||||||
|
minLength: preprocessingConfig.minLength,
|
||||||
|
maxLength: preprocessingConfig.maxLength,
|
||||||
|
irrelevantPatterns: preprocessingConfig.irrelevantPatterns.length > 0
|
||||||
|
? preprocessingConfig.irrelevantPatterns
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}, [input, settings.queryPreprocessing]);
|
||||||
|
|
||||||
|
const isValid = validation.isValid;
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
@@ -21,7 +87,7 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (input.trim() && !isLoading) {
|
if (input.trim() && !isLoading && isValid) {
|
||||||
onSend(input);
|
onSend(input);
|
||||||
setInput("");
|
setInput("");
|
||||||
}
|
}
|
||||||
@@ -30,43 +96,166 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = "auto";
|
textareaRef.current.style.height = "auto";
|
||||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
const scrollHeight = textareaRef.current.scrollHeight;
|
||||||
|
// Limitar altura máxima a uns 200px
|
||||||
|
textareaRef.current.style.height = `${Math.min(scrollHeight, 200)}px`;
|
||||||
}
|
}
|
||||||
}, [input]);
|
}, [input]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-t bg-background">
|
<div className="w-full bg-transparent">
|
||||||
<div className="relative flex items-end gap-2 max-w-3xl mx-auto">
|
<div className="relative w-full">
|
||||||
|
<div className="flex-1 relative bg-background dark:bg-zinc-900/50 rounded-[2rem] border border-border/50 shadow-lg backdrop-blur-xl transition-all duration-200 focus-within:ring-2 focus-within:ring-primary/20 focus-within:border-primary/40 hover:border-primary/30">
|
||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Digite sua mensagem..."
|
placeholder="Pergunte qualquer coisa..."
|
||||||
className="min-h-[50px] max-h-[200px] resize-none pr-12 py-3"
|
className={`min-h-[52px] sm:min-h-[56px] max-h-[200px] w-full resize-none bg-transparent py-3 sm:py-4 pl-4 sm:pl-6 pr-28 sm:pr-32 text-[16px] sm:text-lg rounded-[2rem] border-0 focus-visible:ring-0 shadow-none placeholder:text-muted-foreground/50 ${
|
||||||
|
input.trim() && !isValid ? 'text-destructive' : ''
|
||||||
|
}`}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 bottom-2">
|
|
||||||
|
{/* Right Actions */}
|
||||||
|
<div className="absolute right-2 sm:right-3 bottom-2 sm:bottom-2.5 flex items-center gap-1 sm:gap-1.5">
|
||||||
|
{onWebSearchToggle && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant={webSearchEnabled ? "ghost" : "ghost"}
|
||||||
|
onClick={() => {
|
||||||
|
if (!webSearchEnabled) {
|
||||||
|
onWebSearchToggle(true);
|
||||||
|
} else {
|
||||||
|
setWebDialogOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`h-9 w-9 rounded-full transition-all ${
|
||||||
|
webSearchEnabled
|
||||||
|
? "text-primary bg-primary/10 hover:bg-primary/20"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{webSearchEnabled ? (
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{webSearchEnabled
|
||||||
|
? "Pesquisa web ativada"
|
||||||
|
: "Pesquisa web desativada"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onToggleCategory && categories && categories.length > 0 && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="icon" variant="ghost" className="h-9 w-9 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted" title="Gerenciar fontes">
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" sideOffset={12} className="w-56 rounded-xl">
|
||||||
|
<DropdownMenuLabel>Fontes de conhecimento</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{categories.map((cat) => {
|
||||||
|
// Mapeamento de ícones por categoria
|
||||||
|
const getCategoryIcon = (categoryId: string) => {
|
||||||
|
const iconMap: Record<string, React.ReactNode> = {
|
||||||
|
'academic': <GraduationCap className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||||
|
'news': <Newspaper className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||||
|
'tech': <Terminal className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||||
|
'finance': <TrendingUp className="w-4 h-4 text-muted-foreground shrink-0" />,
|
||||||
|
};
|
||||||
|
return iconMap[categoryId] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={cat.id}
|
||||||
|
checked={cat.enabled}
|
||||||
|
onCheckedChange={(checked) => onToggleCategory(cat.id, !!checked)}
|
||||||
|
className="pl-8"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{getCategoryIcon(cat.id)}
|
||||||
|
<span>{cat.name}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
|
<Button
|
||||||
<Square className="h-4 w-4 fill-current" />
|
size="icon"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onStop}
|
||||||
|
className="h-9 w-9 rounded-full shadow-sm animate-in fade-in zoom-in duration-200"
|
||||||
|
>
|
||||||
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={!input.trim()}
|
disabled={!input.trim() || !isValid}
|
||||||
className="h-8 w-8"
|
className={`h-9 w-9 rounded-full transition-all shadow-sm ${
|
||||||
|
input.trim() && isValid
|
||||||
|
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
: "bg-muted text-muted-foreground cursor-not-allowed"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4 ml-0.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-center text-muted-foreground mt-2">
|
|
||||||
OllaHub pode cometer erros. Verifique informações importantes.
|
{/* Validation Errors */}
|
||||||
|
{input.trim() && !isValid && validation.errors.length > 0 && (
|
||||||
|
<div className="absolute -top-12 left-0 right-0 mx-auto w-max max-w-[90%] animate-in fade-in slide-in-from-bottom-2">
|
||||||
|
<div className="bg-destructive/90 text-destructive-foreground text-xs px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 backdrop-blur-sm">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
<span>{validation.errors[0]}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={webDialogOpen} onOpenChange={setWebDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px] rounded-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Desativar pesquisa web?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Desativar a busca pode reduzir a qualidade em perguntas sobre fatos recentes.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={() => setWebDialogOpen(false)} className="rounded-full">Cancelar</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => { onWebSearchToggle?.(false); setWebDialogOpen(false); }}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
Desativar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,44 +1,235 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
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";
|
||||||
import { markdownRenderers } from "./markdown-renderers";
|
import { markdownRenderers } from "./markdown-renderers";
|
||||||
import { MessageActions } from "./message-actions";
|
import { MessageActions } from "./message-actions";
|
||||||
|
import { MessageBackground } from "./message-background";
|
||||||
|
import { MessageStepThinking } from "./message-step-thinking";
|
||||||
|
import type { ThinkingMessageMetadata } from "@/hooks/use-chat";
|
||||||
|
|
||||||
|
interface ChatMessageProps {
|
||||||
|
message: Message;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
highlightTerm?: string;
|
||||||
|
highlightIndex?: number;
|
||||||
|
messageIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
const isThinkingMessage = message.metadata &&
|
||||||
|
typeof message.metadata === 'object' &&
|
||||||
|
'type' in message.metadata &&
|
||||||
|
message.metadata.type === 'thinking';
|
||||||
|
|
||||||
|
if (isThinkingMessage) {
|
||||||
|
return <MessageStepThinking metadata={message.metadata as ThinkingMessageMetadata} />;
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatMessage({ message }: { message: Message }) {
|
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
|
const [selected, setSelected] = useState(false);
|
||||||
|
const isEmpty = !message.content || message.content.trim() === '';
|
||||||
|
|
||||||
|
// Memoizar processamento de conteúdo
|
||||||
|
const processedContent = useMemo(() => {
|
||||||
|
let content = message.content || '';
|
||||||
|
// Remove metadata tags
|
||||||
|
content = content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||||
|
|
||||||
|
// Also remove common prefixes
|
||||||
|
const prefixes = [
|
||||||
|
'Bloco oculto de metadados:', 'Metadata:', 'Metadados:',
|
||||||
|
'Hidden metadata block:', 'JSON metadata:', '---'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||||
|
if (regex.test(content)) {
|
||||||
|
content = content.replace(regex, '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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
|
||||||
|
role={isUser ? 'user' : 'assistant'}
|
||||||
|
state={selected ? 'selected' : 'normal'}
|
||||||
|
className={cn(
|
||||||
|
"cursor-default transition-colors rounded-xl",
|
||||||
|
selected && "bg-muted/40",
|
||||||
|
isUser ? "mb-2" : "mb-8"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelected((v) => !v)}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"group flex w-full gap-4 p-6 relative",
|
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm ring-1 ring-border/50 backdrop-blur-sm",
|
||||||
isUser ? "bg-background" : "bg-muted/30"
|
isUser
|
||||||
|
? "bg-background text-muted-foreground"
|
||||||
|
: "bg-primary/10 text-primary ring-primary/20"
|
||||||
)}>
|
)}>
|
||||||
<div className="flex-shrink-0 mt-1">
|
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-5 h-5" />}
|
||||||
<div className={cn(
|
|
||||||
"w-8 h-8 rounded-sm flex items-center justify-center",
|
|
||||||
isUser ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground"
|
|
||||||
)}>
|
|
||||||
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden min-w-0">
|
|
||||||
<div className="prose dark:prose-invert max-w-none break-words">
|
<div className="flex-1 overflow-hidden min-w-0 pt-1" data-message-index={messageIndex}>
|
||||||
|
{isUser ? (
|
||||||
|
// 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"
|
||||||
|
dangerouslySetInnerHTML={highlightedContent ? { __html: highlightedContent } : undefined}
|
||||||
|
>
|
||||||
|
{!highlightedContent && processedContent}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Estilo Assistant
|
||||||
|
isEmpty && isStreaming ? (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground py-1">
|
||||||
|
<div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
|
||||||
|
<span className="text-sm font-medium">Gerando resposta...</span>
|
||||||
|
</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(
|
||||||
|
"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-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-mark:bg-yellow-200/50 prose-mark:dark:bg-yellow-800/50 prose-mark:px-0.5 prose-mark:rounded"
|
||||||
|
)}>
|
||||||
|
{highlightedContent ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
|
||||||
|
) : (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={markdownRenderers}
|
components={markdownRenderers}
|
||||||
>
|
>
|
||||||
{message.content}
|
{processedContent}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions Toolbar - Absolute positioned or flex end */}
|
{!isUser && !isStreaming && (
|
||||||
{!isUser && (
|
<div className={cn(
|
||||||
<div className="absolute top-4 right-4">
|
"absolute -bottom-8 left-12 flex items-center gap-2 opacity-0 transition-opacity duration-200",
|
||||||
|
"group-hover:opacity-100",
|
||||||
|
selected && "opacity-100"
|
||||||
|
)}>
|
||||||
<MessageActions content={message.content} role={message.role} />
|
<MessageActions content={message.content} role={message.role} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</MessageBackground>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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';
|
||||||
@@ -43,3 +43,5 @@ export function CodeBlock({ language, children }: CodeBlockProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AlertCircle, RefreshCw, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
error: string | Error;
|
||||||
|
onRetry?: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorDisplay({ error, onRetry, onDismiss, className }: ErrorDisplayProps) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-4 mb-3 px-4 py-3 rounded-lg border border-red-500/50 bg-red-500/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-red-600 dark:text-red-400 mb-1">
|
||||||
|
Erro ao processar
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300 break-words">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
{(onRetry || onDismiss) && (
|
||||||
|
<div className="flex items-center gap-2 mt-3">
|
||||||
|
{onRetry && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="h-7 text-xs border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-500/20"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3 mr-1.5" />
|
||||||
|
Tentar novamente
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onDismiss && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="h-7 text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 mr-1.5" />
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -136,3 +136,5 @@ export const markdownRenderers: Components = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Loader2, Play, Square, RefreshCw, Save } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { McpJsonEditor } from '@/components/chat/mcp-json-editor';
|
||||||
|
|
||||||
|
interface McpServerConfig {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpConfig {
|
||||||
|
mcpServers: Record<string, McpServerConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface McpServerStatus {
|
||||||
|
name: string;
|
||||||
|
status: 'running' | 'stopped' | 'error';
|
||||||
|
pid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpConfigPanel() {
|
||||||
|
const [config, setConfig] = useState<McpConfig>({ mcpServers: {} });
|
||||||
|
const [statuses, setStatuses] = useState<McpServerStatus[]>([]);
|
||||||
|
const [configJson, setConfigJson] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [configPath, setConfigPath] = useState('');
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
loadStatus();
|
||||||
|
loadConfigPath();
|
||||||
|
|
||||||
|
// Refresh status every 5 seconds
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const loadedConfig = await invoke<any>('load_mcp_config');
|
||||||
|
// Convert from Rust HashMap to TypeScript Record
|
||||||
|
// Always ensure minimum structure: { mcpServers: {} }
|
||||||
|
const config: McpConfig = {
|
||||||
|
mcpServers: loadedConfig.mcp_servers || loadedConfig.mcpServers || {}
|
||||||
|
};
|
||||||
|
setConfig(config);
|
||||||
|
setConfigJson(JSON.stringify(config, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load MCP config:', error);
|
||||||
|
// On error, set minimum structure
|
||||||
|
const minConfig: McpConfig = { mcpServers: {} };
|
||||||
|
setConfig(minConfig);
|
||||||
|
setConfigJson(JSON.stringify(minConfig, null, 2));
|
||||||
|
toast({
|
||||||
|
title: 'Erro',
|
||||||
|
description: 'Falha ao carregar configuração MCP',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
try {
|
||||||
|
const serverStatuses = await invoke<McpServerStatus[]>('list_mcp_server_status');
|
||||||
|
setStatuses(serverStatuses);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load MCP status:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadConfigPath = async () => {
|
||||||
|
try {
|
||||||
|
const path = await invoke<string>('get_mcp_config_path_command');
|
||||||
|
setConfigPath(path);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config path:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveConfig = async () => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
// Trim whitespace from JSON string
|
||||||
|
const trimmedJson = configJson.trim();
|
||||||
|
|
||||||
|
// Validate JSON structure
|
||||||
|
if (!trimmedJson) {
|
||||||
|
toast({
|
||||||
|
title: 'Erro',
|
||||||
|
description: 'JSON vazio. Por favor, insira uma configuração válida.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedConfig: McpConfig;
|
||||||
|
try {
|
||||||
|
parsedConfig = JSON.parse(trimmedJson);
|
||||||
|
} catch (e) {
|
||||||
|
const errorMessage = e instanceof Error ? e.message : 'Erro desconhecido ao fazer parse do JSON';
|
||||||
|
console.error('JSON parse error:', e);
|
||||||
|
toast({
|
||||||
|
title: 'Erro de JSON',
|
||||||
|
description: `JSON inválido: ${errorMessage}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required structure
|
||||||
|
if (!parsedConfig || typeof parsedConfig !== 'object') {
|
||||||
|
toast({
|
||||||
|
title: 'Erro',
|
||||||
|
description: 'Configuração inválida: deve ser um objeto JSON.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure mcpServers exists and is an object
|
||||||
|
if (!parsedConfig.mcpServers || typeof parsedConfig.mcpServers !== 'object') {
|
||||||
|
parsedConfig.mcpServers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always ensure minimum structure: { mcpServers: {} }
|
||||||
|
const normalizedConfig: McpConfig = {
|
||||||
|
mcpServers: parsedConfig.mcpServers || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The Rust struct uses serde(rename = "mcpServers") so we can pass it directly
|
||||||
|
// No need to convert mcpServers to mcp_servers
|
||||||
|
await invoke('save_mcp_config', { config: normalizedConfig });
|
||||||
|
setConfig(normalizedConfig);
|
||||||
|
// Update JSON to reflect normalized structure
|
||||||
|
setConfigJson(JSON.stringify(normalizedConfig, null, 2));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Sucesso',
|
||||||
|
description: 'Configuração MCP salva com sucesso',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload status after saving
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save MCP config:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido';
|
||||||
|
toast({
|
||||||
|
title: 'Erro',
|
||||||
|
description: `Falha ao salvar configuração MCP: ${errorMessage}`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startServer = async (name: string) => {
|
||||||
|
try {
|
||||||
|
const serverConfig = config.mcpServers[name];
|
||||||
|
if (!serverConfig) {
|
||||||
|
toast({
|
||||||
|
title: 'Erro',
|
||||||
|
description: `Servidor '${name}' não encontrado na configuração`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await invoke('start_mcp_server', { name, config: serverConfig });
|
||||||
|
toast({
|
||||||
|
title: 'Sucesso',
|
||||||
|
description: `Servidor '${name}' iniciado`,
|
||||||
|
});
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start server:', error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
toast({
|
||||||
|
title: 'Erro ao Iniciar Servidor',
|
||||||
|
description: errorMessage || `Falha ao iniciar servidor '${name}'`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopServer = async (name: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('stop_mcp_server', { name });
|
||||||
|
toast({
|
||||||
|
title: 'Sucesso',
|
||||||
|
description: `Servidor '${name}' parado`,
|
||||||
|
});
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to stop server:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Erro',
|
||||||
|
description: `Falha ao parar servidor '${name}'`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartServer = async (name: string) => {
|
||||||
|
try {
|
||||||
|
await invoke('restart_mcp_server', { name });
|
||||||
|
toast({
|
||||||
|
title: 'Sucesso',
|
||||||
|
description: `Servidor '${name}' reiniciado`,
|
||||||
|
});
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restart server:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Erro',
|
||||||
|
description: `Falha ao reiniciar servidor '${name}'`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartAll = async () => {
|
||||||
|
try {
|
||||||
|
const started = await invoke<string[]>('restart_all_mcp_servers');
|
||||||
|
toast({
|
||||||
|
title: 'Sucesso',
|
||||||
|
description: `${started.length} servidor(es) iniciado(s)`,
|
||||||
|
});
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restart all servers:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Erro',
|
||||||
|
description: 'Falha ao reiniciar servidores',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadgeVariant = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'default';
|
||||||
|
case 'stopped':
|
||||||
|
return 'secondary';
|
||||||
|
case 'error':
|
||||||
|
return 'destructive';
|
||||||
|
default:
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-4 p-4 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">MCP Servers</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Gerencie servidores Model Context Protocol
|
||||||
|
</p>
|
||||||
|
{configPath && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Config: <code className="text-xs bg-muted px-1 py-0.5 rounded">{configPath}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={restartAll} size="sm" variant="outline">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Reiniciar Todos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Status List */}
|
||||||
|
<div className="space-y-2 flex-shrink-0 overflow-y-auto">
|
||||||
|
{Object.keys(config.mcpServers).length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Nenhum servidor MCP configurado. Adicione servidores no JSON abaixo.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
Object.keys(config.mcpServers).map((name) => {
|
||||||
|
const status = statuses.find(s => s.name === name);
|
||||||
|
const isRunning = status?.status === 'running';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={name}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-base">{name}</CardTitle>
|
||||||
|
{status && (
|
||||||
|
<Badge variant={getStatusBadgeVariant(status.status)}>
|
||||||
|
{status.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{status?.pid && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
PID: {status.pid}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isRunning ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => stopServer(name)}
|
||||||
|
>
|
||||||
|
<Square className="w-3 h-3 mr-1" />
|
||||||
|
Parar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => restartServer(name)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3 mr-1" />
|
||||||
|
Reiniciar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => startServer(name)}
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3 mr-1" />
|
||||||
|
Iniciar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Comando:</span>{' '}
|
||||||
|
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||||
|
{config.mcpServers[name].command} {config.mcpServers[name].args.join(' ')}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
{config.mcpServers[name].env && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Env:</span>{' '}
|
||||||
|
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||||
|
{Object.keys(config.mcpServers[name].env || {}).join(', ')}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON Editor */}
|
||||||
|
<Card className="flex flex-col flex-1 min-h-0 max-w-full overflow-hidden">
|
||||||
|
<CardHeader className="flex-shrink-0">
|
||||||
|
<CardTitle className="text-base">Configuração JSON</CardTitle>
|
||||||
|
<CardDescription className="truncate">
|
||||||
|
Edite a configuração MCP no formato JSON padrão
|
||||||
|
</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button onClick={saveConfig} disabled={isSaving} size="sm">
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 flex flex-col min-h-0 max-w-full overflow-hidden">
|
||||||
|
<McpJsonEditor
|
||||||
|
value={configJson}
|
||||||
|
onChange={setConfigJson}
|
||||||
|
placeholder='{\n "mcpServers": {\n "filesystem": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]\n }\n }\n}'
|
||||||
|
className="flex-1 max-w-full h-full"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AlertCircle, CheckCircle2, Wand2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface McpJsonEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpJsonEditor({ value, onChange, placeholder, className }: McpJsonEditorProps) {
|
||||||
|
const { theme, resolvedTheme } = useTheme();
|
||||||
|
const [isValid, setIsValid] = useState(true);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isFormatted, setIsFormatted] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const highlightContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Determine theme
|
||||||
|
const isDark = resolvedTheme === 'dark' || theme === 'dark';
|
||||||
|
const codeStyle = isDark ? vscDarkPlus : vs;
|
||||||
|
|
||||||
|
// Validate JSON and update state
|
||||||
|
const validateJson = (jsonString: string) => {
|
||||||
|
if (!jsonString.trim()) {
|
||||||
|
setIsValid(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(jsonString);
|
||||||
|
setIsValid(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
} catch (e) {
|
||||||
|
setIsValid(false);
|
||||||
|
const error = e instanceof Error ? e.message : 'JSON inválido';
|
||||||
|
setErrorMessage(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format JSON
|
||||||
|
const formatJson = () => {
|
||||||
|
if (!value.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
const formatted = JSON.stringify(parsed, null, 2);
|
||||||
|
onChange(formatted);
|
||||||
|
setIsFormatted(true);
|
||||||
|
setTimeout(() => setIsFormatted(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
// If invalid, show error
|
||||||
|
validateJson(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle input changes
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
onChange(newValue);
|
||||||
|
validateJson(newValue);
|
||||||
|
setIsFormatted(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync scroll between textarea and highlight
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (textareaRef.current && highlightContainerRef.current) {
|
||||||
|
const scrollTop = textareaRef.current.scrollTop;
|
||||||
|
const scrollLeft = textareaRef.current.scrollLeft;
|
||||||
|
|
||||||
|
const pre = highlightContainerRef.current.querySelector('pre');
|
||||||
|
if (pre) {
|
||||||
|
pre.scrollTop = scrollTop;
|
||||||
|
pre.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate on mount and when value changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
validateJson(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Count lines for display
|
||||||
|
const lineCount = value.split('\n').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative flex flex-col h-full min-h-0', className)}>
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-between mb-2 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={formatJson}
|
||||||
|
disabled={!value.trim()}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Wand2 className="w-3 h-3 mr-1" />
|
||||||
|
Formatar
|
||||||
|
</Button>
|
||||||
|
{isFormatted && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
Formatado
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{lineCount > 0 && <span>{lineCount} linha{lineCount !== 1 ? 's' : ''}</span>}
|
||||||
|
{!isValid && errorMessage && (
|
||||||
|
<div className="flex items-center gap-1 text-destructive">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
<span className="max-w-[200px] truncate">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isValid && value.trim() && (
|
||||||
|
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
Válido
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor Container */}
|
||||||
|
<div className="relative flex-1 min-h-[300px] border rounded-md overflow-hidden bg-background">
|
||||||
|
{/* Syntax Highlighted Background */}
|
||||||
|
<div ref={highlightContainerRef} className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language="json"
|
||||||
|
style={codeStyle}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: 'transparent',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
lineHeight: '1.5rem',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
PreTag="pre"
|
||||||
|
>
|
||||||
|
{value || placeholder || '{}'}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editable Textarea */}
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(
|
||||||
|
'relative w-full h-full min-h-[300px] p-3 font-mono text-sm leading-6',
|
||||||
|
'bg-transparent text-transparent caret-foreground',
|
||||||
|
'resize-none border-none outline-none',
|
||||||
|
'selection:bg-primary/20',
|
||||||
|
'[&::placeholder]:text-muted-foreground/50',
|
||||||
|
!isValid && 'ring-1 ring-destructive'
|
||||||
|
)}
|
||||||
|
spellCheck={false}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
overflow: 'auto',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'auto',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Server, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { McpServerInfo } from '@/hooks/use-mcp-servers';
|
||||||
|
|
||||||
|
interface McpServerSelectorProps {
|
||||||
|
servers: McpServerInfo[];
|
||||||
|
selectedServers: string[];
|
||||||
|
onSelectionChange: (servers: string[]) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function McpServerSelector({
|
||||||
|
servers,
|
||||||
|
selectedServers,
|
||||||
|
onSelectionChange,
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
}: McpServerSelectorProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Filter only running servers
|
||||||
|
const runningServers = servers.filter(s => s.status === 'running');
|
||||||
|
|
||||||
|
const handleToggle = (serverName: string) => {
|
||||||
|
if (selectedServers.includes(serverName)) {
|
||||||
|
onSelectionChange(selectedServers.filter(s => s !== serverName));
|
||||||
|
} else {
|
||||||
|
onSelectionChange([...selectedServers, serverName]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectedServers.length === runningServers.length) {
|
||||||
|
onSelectionChange([]);
|
||||||
|
} else {
|
||||||
|
onSelectionChange(runningServers.map(s => s.name));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (selectedServers.length === 0) {
|
||||||
|
return 'Servidores MCP';
|
||||||
|
}
|
||||||
|
if (selectedServers.length === 1) {
|
||||||
|
return selectedServers[0];
|
||||||
|
}
|
||||||
|
return `${selectedServers.length} servidores`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className={cn('h-8 gap-2', className)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Server className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">{getButtonText()}</span>
|
||||||
|
{selectedServers.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1 h-5 px-1.5 text-xs">
|
||||||
|
{selectedServers.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel className="flex items-center justify-between">
|
||||||
|
<span>Servidores MCP</span>
|
||||||
|
{selectedServers.length > 0 && (
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{selectedServers.length} selecionado{selectedServers.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={selectedServers.length === runningServers.length && runningServers.length > 0}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
className="font-medium"
|
||||||
|
>
|
||||||
|
Selecionar Todos
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{runningServers.map((server) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={server.name}
|
||||||
|
checked={selectedServers.includes(server.name)}
|
||||||
|
onCheckedChange={() => handleToggle(server.name)}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
|
||||||
|
<span>{server.name}</span>
|
||||||
|
</div>
|
||||||
|
{server.toolCount !== undefined && server.toolCount > 0 && (
|
||||||
|
<Badge variant="outline" className="ml-2 h-5 px-1.5 text-xs">
|
||||||
|
{server.toolCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
{runningServers.length === 0 && (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||||
|
Nenhum servidor rodando
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Copy, FileJson, FileText, MoreHorizontal } from "lucide-react";
|
import { Copy, FileJson, FileText, MoreHorizontal, Contrast } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface MessageActionsProps {
|
interface MessageActionsProps {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -15,6 +15,16 @@ interface MessageActionsProps {
|
|||||||
|
|
||||||
export function MessageActions({ content, role }: MessageActionsProps) {
|
export function MessageActions({ content, role }: MessageActionsProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [highContrast, setHighContrast] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = typeof window !== 'undefined' ? localStorage.getItem('highContrast') === 'true' : false;
|
||||||
|
setHighContrast(saved);
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
if (saved) document.documentElement.setAttribute('data-high-contrast', 'true');
|
||||||
|
else document.documentElement.removeAttribute('data-high-contrast');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCopy = (text: string) => {
|
const handleCopy = (text: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
@@ -73,9 +83,27 @@ export function MessageActions({ content, role }: MessageActionsProps) {
|
|||||||
<FileJson className="mr-2 h-4 w-4" />
|
<FileJson className="mr-2 h-4 w-4" />
|
||||||
<span>Copiar JSON</span>
|
<span>Copiar JSON</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const next = !highContrast;
|
||||||
|
setHighContrast(next);
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
if (next) document.documentElement.setAttribute('data-high-contrast', 'true');
|
||||||
|
else document.documentElement.removeAttribute('data-high-contrast');
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('highContrast', String(next));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Contrast className="mr-2 h-4 w-4" />
|
||||||
|
<span>{highContrast ? 'Desativar Alto Contraste' : 'Ativar Alto Contraste'}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type MessageRole = "user" | "assistant";
|
||||||
|
type MessageVisualState = "normal" | "highlighted" | "selected";
|
||||||
|
|
||||||
|
interface MessageBackgroundProps extends React.ComponentProps<"div"> {
|
||||||
|
role: MessageRole;
|
||||||
|
state?: MessageVisualState;
|
||||||
|
highContrast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBackground({
|
||||||
|
role,
|
||||||
|
state = "normal",
|
||||||
|
highContrast = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBackgroundProps) {
|
||||||
|
const base = "group flex w-full gap-4 md:gap-6 relative transition-all duration-200";
|
||||||
|
|
||||||
|
// User: Fundo sutil, alinhado à direita (se quiséssemos, mas o layout atual é lista).
|
||||||
|
// Vamos manter layout lista mas com estilo clean.
|
||||||
|
// Perplexity usa: User message apenas texto bold/destacado, Assistant texto normal.
|
||||||
|
|
||||||
|
const roleCl = role === "user"
|
||||||
|
? "bg-transparent text-foreground px-0 py-2" // Minimalista
|
||||||
|
: "bg-transparent text-foreground/90 px-0 py-2";
|
||||||
|
|
||||||
|
// Estado de seleção sutil
|
||||||
|
const stateCl = state === "selected" ? "bg-muted/30 -mx-4 px-4 rounded-lg" : "";
|
||||||
|
|
||||||
|
const hc = highContrast ? "hc:bg-message-high-contrast hc:text-foreground" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-selected={state === "selected"}
|
||||||
|
className={cn(base, roleCl, stateCl, hc, className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Brain,
|
||||||
|
Globe,
|
||||||
|
Package,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
Check
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||||
|
|
||||||
|
interface MessageStepThinkingProps {
|
||||||
|
metadata: ThinkingMessageMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageStepThinking({ metadata }: MessageStepThinkingProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const getStepIcon = () => {
|
||||||
|
const isSpinning = metadata.stepType === 'response-generation' && metadata.status === 'running';
|
||||||
|
|
||||||
|
switch (metadata.stepType) {
|
||||||
|
case 'preprocessing':
|
||||||
|
return <Brain className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||||
|
case 'web-research':
|
||||||
|
return <Globe className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||||
|
case 'sources-found':
|
||||||
|
return <Package className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||||
|
case 'processing':
|
||||||
|
return <Brain className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||||
|
case 'response-generation':
|
||||||
|
return <Loader2 className={cn("w-4 h-4 text-muted-foreground/80", isSpinning && "animate-spin")} />;
|
||||||
|
case 'fallback':
|
||||||
|
return <AlertCircle className="w-4 h-4 text-muted-foreground/80" />;
|
||||||
|
case 'error':
|
||||||
|
return <XCircle className="w-4 h-4 text-destructive/80" />;
|
||||||
|
default:
|
||||||
|
return <Search className="w-4 h-4 text-muted-foreground/80" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (url: string, index: number) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopiedIndex(index);
|
||||||
|
setTimeout(() => setCopiedIndex(null), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy URL:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 ml-10 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
<div className="flex items-center gap-3 p-2 transition-all w-fit max-w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStepIcon()}
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
metadata.status === 'error'
|
||||||
|
? "text-destructive/80"
|
||||||
|
: "text-muted-foreground/80"
|
||||||
|
)}>
|
||||||
|
{metadata.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{metadata.status === 'running' && (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(metadata.details || metadata.sources || metadata.error) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-3.5 h-3.5 opacity-50" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar Compacta */}
|
||||||
|
{metadata.progress !== undefined && metadata.progress < 100 && (
|
||||||
|
<div className="ml-1 mt-1 w-32 h-1 bg-muted/50 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary/50 transition-all duration-300"
|
||||||
|
style={{ width: `${metadata.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Conteúdo Expandido */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-2 ml-1 p-3 border-l-2 border-muted/30 space-y-2 text-sm text-muted-foreground animate-in fade-in">
|
||||||
|
{metadata.details && (
|
||||||
|
<div className="whitespace-pre-wrap">{metadata.details}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadata.sources && metadata.sources.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-1">
|
||||||
|
{metadata.sources.map((source, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1 bg-background/50 border border-muted/50 px-2 py-1 rounded text-xs max-w-[200px]">
|
||||||
|
<ExternalLink className="w-3 h-3 opacity-50" />
|
||||||
|
<a href={source.url} target="_blank" className="truncate hover:underline flex-1">{source.title || source.url}</a>
|
||||||
|
<button onClick={() => copyToClipboard(source.url, idx)} className="ml-1 opacity-50 hover:opacity-100">
|
||||||
|
{copiedIndex === idx ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadata.error && (
|
||||||
|
<div className="text-red-500 font-medium flex items-center gap-2">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
{metadata.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Download, Loader2, X, Upload, Globe, AlertCircle, File } from "lucide-react";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
// Usar file dialog do Tauri v2
|
||||||
|
|
||||||
|
interface ModelDownloadDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess?: (modelName: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadInfo {
|
||||||
|
status: string;
|
||||||
|
percent?: number;
|
||||||
|
downloaded?: string;
|
||||||
|
total?: string;
|
||||||
|
speed?: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelDownloadDialog({ open, onOpenChange, onSuccess }: ModelDownloadDialogProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<"download" | "local">("download");
|
||||||
|
const [modelName, setModelName] = useState("");
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
|
const [downloadStatus, setDownloadStatus] = useState("");
|
||||||
|
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isInstalled, setIsInstalled] = useState(false);
|
||||||
|
const isDownloadingRef = useRef(false);
|
||||||
|
const modelNameRef = useRef("");
|
||||||
|
|
||||||
|
// Estados para instalação local
|
||||||
|
const [isInstallingLocal, setIsInstallingLocal] = useState(false);
|
||||||
|
const [localInstallProgress, setLocalInstallProgress] = useState(0);
|
||||||
|
const [localInstallError, setLocalInstallError] = useState<string | null>(null);
|
||||||
|
const [localModelName, setLocalModelName] = useState("");
|
||||||
|
|
||||||
|
// Atualizar refs quando estado muda
|
||||||
|
useEffect(() => {
|
||||||
|
isDownloadingRef.current = isDownloading;
|
||||||
|
modelNameRef.current = modelName;
|
||||||
|
}, [isDownloading, modelName]);
|
||||||
|
|
||||||
|
// Resetar estado quando dialog abre/fecha
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setActiveTab("download");
|
||||||
|
setModelName("");
|
||||||
|
setIsDownloading(false);
|
||||||
|
setDownloadProgress(0);
|
||||||
|
setDownloadStatus("");
|
||||||
|
setDownloadInfo(null);
|
||||||
|
setError(null);
|
||||||
|
setIsInstalled(false);
|
||||||
|
isDownloadingRef.current = false;
|
||||||
|
modelNameRef.current = "";
|
||||||
|
// Reset estados locais
|
||||||
|
setIsInstallingLocal(false);
|
||||||
|
setLocalInstallProgress(0);
|
||||||
|
setLocalInstallError(null);
|
||||||
|
setLocalModelName("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Verificar se modelo já está instalado quando nome muda
|
||||||
|
useEffect(() => {
|
||||||
|
if (modelName.trim() && !isDownloading) {
|
||||||
|
checkInstallation(modelName.trim());
|
||||||
|
}
|
||||||
|
}, [modelName, isDownloading]);
|
||||||
|
|
||||||
|
// Listener para progresso do download - sempre ativo quando dialog está aberto
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
let unlistenFn: (() => void) | null = null;
|
||||||
|
|
||||||
|
const setupListener = async () => {
|
||||||
|
const unlisten = await listen<string>('download-progress', (event) => {
|
||||||
|
// Só processar se estiver fazendo download (usar ref para evitar stale closure)
|
||||||
|
if (!isDownloadingRef.current) return;
|
||||||
|
|
||||||
|
const payload = event.payload;
|
||||||
|
|
||||||
|
// Tentar parsear como JSON estruturado
|
||||||
|
let info: DownloadInfo | null = null;
|
||||||
|
try {
|
||||||
|
info = JSON.parse(payload) as DownloadInfo;
|
||||||
|
} catch {
|
||||||
|
// Fallback: tratar como string raw
|
||||||
|
info = {
|
||||||
|
status: "downloading",
|
||||||
|
raw: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info) {
|
||||||
|
setDownloadInfo(info);
|
||||||
|
|
||||||
|
// Atualizar status text
|
||||||
|
const statusText = info.status === "pulling" ? "Baixando modelo..." :
|
||||||
|
info.status === "verifying" ? "Verificando..." :
|
||||||
|
info.status === "writing" ? "Salvando..." :
|
||||||
|
info.status === "success" ? "Concluído!" :
|
||||||
|
"Baixando...";
|
||||||
|
setDownloadStatus(statusText);
|
||||||
|
|
||||||
|
// Atualizar progresso
|
||||||
|
if (info.percent !== undefined) {
|
||||||
|
setDownloadProgress(info.percent);
|
||||||
|
} else {
|
||||||
|
// Fallback: estimar progresso baseado no status
|
||||||
|
if (info.status === "pulling") {
|
||||||
|
setDownloadProgress(prev => prev < 10 ? 10 : prev);
|
||||||
|
} else if (info.status === "verifying") {
|
||||||
|
setDownloadProgress(90);
|
||||||
|
} else if (info.status === "writing") {
|
||||||
|
setDownloadProgress(95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay
|
||||||
|
if (info.percent === 100 || info.status === "success") {
|
||||||
|
setTimeout(async () => {
|
||||||
|
setIsInstalled(true);
|
||||||
|
setIsDownloading(false);
|
||||||
|
isDownloadingRef.current = false;
|
||||||
|
const currentModelName = modelNameRef.current.trim();
|
||||||
|
await checkInstallation(currentModelName);
|
||||||
|
// Após verificar instalação, chamar onSuccess e fechar
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(currentModelName);
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unlistenFn = unlisten;
|
||||||
|
};
|
||||||
|
|
||||||
|
setupListener();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlistenFn) {
|
||||||
|
unlistenFn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [open, onSuccess, onOpenChange]);
|
||||||
|
|
||||||
|
const checkInstallation = async (name: string) => {
|
||||||
|
try {
|
||||||
|
const installed = await invoke<boolean>('check_if_model_installed', { name });
|
||||||
|
setIsInstalled(installed);
|
||||||
|
if (installed) {
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Check failed", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateModelName = (name: string): string | null => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "Nome do modelo não pode estar vazio";
|
||||||
|
}
|
||||||
|
// Validar formato: aceita letras, números, pontos, underscores, hífens, dois pontos e barras
|
||||||
|
// Exemplos válidos: "llama3.2:1b", "huihui_ai/qwen3-abliterated:1.7b", "mistral:latest"
|
||||||
|
if (!/^[a-zA-Z0-9._:\/-]+$/.test(trimmed)) {
|
||||||
|
return "Nome do modelo contém caracteres inválidos";
|
||||||
|
}
|
||||||
|
// Não pode começar ou terminar com barra, dois pontos ou hífen
|
||||||
|
if (/^[\/:\-]|[\/:\-]$/.test(trimmed)) {
|
||||||
|
return "Nome do modelo não pode começar ou terminar com /, : ou -";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
const trimmedName = modelName.trim();
|
||||||
|
const validationError = validateModelName(trimmedName);
|
||||||
|
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInstalled) {
|
||||||
|
// Se já está instalado, apenas fechar e notificar sucesso
|
||||||
|
handleSuccess(trimmedName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
isDownloadingRef.current = true;
|
||||||
|
setDownloadProgress(0);
|
||||||
|
setDownloadStatus("Iniciando download...");
|
||||||
|
setError(null);
|
||||||
|
modelNameRef.current = trimmedName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('pull_model', { name: trimmedName });
|
||||||
|
// O status será atualizado pelo listener quando o download completar
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Download failed", error);
|
||||||
|
setError(error?.message || "Falha no download. Tente novamente.");
|
||||||
|
setDownloadStatus("");
|
||||||
|
setDownloadProgress(0);
|
||||||
|
setIsDownloading(false);
|
||||||
|
isDownloadingRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuccess = (name: string) => {
|
||||||
|
// Só fechar e chamar onSuccess se realmente completou o download
|
||||||
|
// Se já estava instalado, também chamar onSuccess
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(name);
|
||||||
|
}
|
||||||
|
// Não fechar automaticamente se foi apenas verificação de instalação
|
||||||
|
// Deixar o usuário decidir quando fechar
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
// Permitir fechar sempre, mas avisar se estiver baixando
|
||||||
|
if (isDownloading) {
|
||||||
|
const confirmed = window.confirm("O download está em andamento. Deseja realmente fechar? O download continuará em segundo plano.");
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
// Cancelar download (por enquanto apenas fecha o dialog e para o estado)
|
||||||
|
// TODO: Implementar cancelamento real do processo ollama pull
|
||||||
|
if (isDownloading) {
|
||||||
|
const confirmed = window.confirm("Deseja cancelar o download? O processo continuará em segundo plano.");
|
||||||
|
if (confirmed) {
|
||||||
|
setIsDownloading(false);
|
||||||
|
isDownloadingRef.current = false;
|
||||||
|
setDownloadProgress(0);
|
||||||
|
setDownloadStatus("");
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFileButton = async () => {
|
||||||
|
try {
|
||||||
|
// Usar comando Rust para abrir dialog nativo do sistema
|
||||||
|
// Isso evita limitações do input file HTML5 com arquivos grandes
|
||||||
|
const selectedPath = await invoke<string | null>('open_gguf_file_dialog');
|
||||||
|
|
||||||
|
if (!selectedPath) {
|
||||||
|
// Usuário cancelou
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsInstallingLocal(true);
|
||||||
|
setLocalInstallError(null);
|
||||||
|
setLocalInstallProgress(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLocalInstallProgress(10);
|
||||||
|
|
||||||
|
// Extrair nome do arquivo do caminho
|
||||||
|
const pathParts = selectedPath.split(/[/\\]/);
|
||||||
|
const fileName = pathParts[pathParts.length - 1] || 'model';
|
||||||
|
const fileNameLower = fileName.toLowerCase();
|
||||||
|
const hasGgufExtension = fileNameLower.endsWith('.gguf');
|
||||||
|
|
||||||
|
// Extrair nome do modelo (remover extensão se for .gguf, senão manter)
|
||||||
|
const fileNameWithoutExt = hasGgufExtension
|
||||||
|
? fileName.replace(/\.gguf$/i, '')
|
||||||
|
: fileName;
|
||||||
|
const modelNameToUse = localModelName.trim() || fileNameWithoutExt;
|
||||||
|
|
||||||
|
setLocalInstallProgress(30);
|
||||||
|
|
||||||
|
// Instalar diretamente do caminho (sem precisar copiar para temp primeiro)
|
||||||
|
const installedModelName = await invoke<string>('install_gguf_model', {
|
||||||
|
filePath: selectedPath,
|
||||||
|
modelName: modelNameToUse || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalInstallProgress(100);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(installedModelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err?.message || 'Erro ao instalar modelo GGUF';
|
||||||
|
setLocalInstallError(errorMsg);
|
||||||
|
console.error('Erro ao instalar modelo GGUF:', err);
|
||||||
|
} finally {
|
||||||
|
setIsInstallingLocal(false);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Erro ao abrir dialog de arquivo:', err);
|
||||||
|
setLocalInstallError('Erro ao abrir seletor de arquivo');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValid = modelName.trim().length > 0 && !validateModelName(modelName.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Adicionar Modelo</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Baixe modelos do Ollama ou instale arquivos GGUF locais
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "download" | "local")} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="download" className="flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
Baixar do Ollama
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="local" className="flex items-center gap-2">
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Instalar Local
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="download" className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="model-name">Nome do Modelo</Label>
|
||||||
|
<Input
|
||||||
|
id="model-name"
|
||||||
|
value={modelName}
|
||||||
|
onChange={(e) => setModelName(e.target.value)}
|
||||||
|
placeholder="ex: llama3.2:1b"
|
||||||
|
disabled={isDownloading}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && isValid && !isDownloading) {
|
||||||
|
handleDownload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
)}
|
||||||
|
{isInstalled && !isDownloading && (
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
✓ Este modelo já está instalado
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDownloading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{downloadStatus || "Iniciando..."}</span>
|
||||||
|
<span>{downloadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={downloadProgress} className="h-2" />
|
||||||
|
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{downloadInfo.downloaded && downloadInfo.total
|
||||||
|
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
|
||||||
|
: downloadInfo.downloaded || ""}
|
||||||
|
</span>
|
||||||
|
{downloadInfo.speed && (
|
||||||
|
<span>{downloadInfo.speed}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isInstalled && !isDownloading && (
|
||||||
|
<div className="bg-muted/50 p-3 rounded-md text-sm">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
O modelo está instalado e pronto para uso.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="local" className="space-y-4 mt-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="local-model-name">Nome do Modelo (opcional)</Label>
|
||||||
|
<Input
|
||||||
|
id="local-model-name"
|
||||||
|
value={localModelName}
|
||||||
|
onChange={(e) => setLocalModelName(e.target.value)}
|
||||||
|
placeholder="Deixe vazio para usar o nome do arquivo"
|
||||||
|
disabled={isInstallingLocal}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Se não especificar, o nome será extraído do arquivo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isInstallingLocal ? (
|
||||||
|
<div className="border rounded-xl p-6 bg-muted/30 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-sm">Instalando modelo...</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Processando arquivo...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localInstallProgress > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress value={localInstallProgress} className="h-2" />
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
{localInstallProgress}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="border-2 border-dashed rounded-xl p-8 text-center bg-muted/30">
|
||||||
|
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Selecione um arquivo GGUF para instalar localmente
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={handleSelectFileButton}
|
||||||
|
disabled={isInstallingLocal}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<File className="w-4 h-4 mr-2" />
|
||||||
|
Selecionar arquivo GGUF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localInstallError && (
|
||||||
|
<div className="border border-destructive/50 rounded-xl p-4 bg-destructive/5 space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-sm text-destructive">Erro ao instalar modelo</p>
|
||||||
|
<p className="text-xs text-destructive/80 mt-1">{localInstallError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 border border-border/50 rounded-lg p-3 text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium mb-1">ℹ️ Modelos instalados localmente</p>
|
||||||
|
<p>Funcionam sem conexão à internet. O arquivo será copiado para o diretório do Ollama.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isInstallingLocal}
|
||||||
|
>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
{activeTab === "download" && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (isInstalled && !isDownloading) {
|
||||||
|
handleSuccess(modelName.trim());
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
handleDownload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isValid || isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Baixando...
|
||||||
|
</>
|
||||||
|
) : isInstalled ? (
|
||||||
|
<>
|
||||||
|
Usar Modelo
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Baixar
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronDown, ChevronUp, Clock, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export interface ProcessStep {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'error';
|
||||||
|
timestamp?: number;
|
||||||
|
duration?: number;
|
||||||
|
details?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostProcessingPanelProps {
|
||||||
|
steps: ProcessStep[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostProcessingPanel({ steps, className }: PostProcessingPanelProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (!steps || steps.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedSteps = steps.filter(s => s.status === 'completed').length;
|
||||||
|
const hasErrors = steps.some(s => s.status === 'error');
|
||||||
|
const isRunning = steps.some(s => s.status === 'running');
|
||||||
|
|
||||||
|
const getStepIcon = (step: ProcessStep) => {
|
||||||
|
switch (step.status) {
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />;
|
||||||
|
case 'error':
|
||||||
|
return <XCircle className="w-4 h-4 text-red-600 dark:text-red-400" />;
|
||||||
|
case 'running':
|
||||||
|
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="w-4 h-4 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (ms?: number) => {
|
||||||
|
if (!ms) return '';
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("border-t border-muted bg-muted/20", className)}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full px-6 py-3 flex items-center justify-between hover:bg-muted/40 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{hasErrors ? (
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||||
|
) : isRunning ? (
|
||||||
|
<Loader2 className="w-4 h-4 text-primary animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Processo de Pensamento
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({completedSteps}/{steps.length} etapas)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-6 pb-4 space-y-3">
|
||||||
|
{steps.map((step, idx) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={cn(
|
||||||
|
"p-3 rounded-lg border transition-all",
|
||||||
|
step.status === 'completed' && "bg-green-500/5 border-green-500/20",
|
||||||
|
step.status === 'error' && "bg-red-500/5 border-red-500/20",
|
||||||
|
step.status === 'running' && "bg-primary/5 border-primary/20",
|
||||||
|
step.status === 'pending' && "bg-muted/50 border-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||||
|
{getStepIcon(step)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{step.label}</span>
|
||||||
|
{step.duration && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({formatDuration(step.duration)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.details && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap">
|
||||||
|
{step.details}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{step.error && (
|
||||||
|
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||||
|
{step.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Search, Brain, Package, Loader2, CheckCircle2, Globe } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export type ProcessingStep =
|
||||||
|
| 'collecting'
|
||||||
|
| 'processing'
|
||||||
|
| 'searching'
|
||||||
|
| 'preparing'
|
||||||
|
| 'streaming';
|
||||||
|
|
||||||
|
interface ProcessingIndicatorProps {
|
||||||
|
currentStep: ProcessingStep;
|
||||||
|
progress?: number; // 0-100
|
||||||
|
sourcesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcessingIndicator({
|
||||||
|
currentStep,
|
||||||
|
progress = 0,
|
||||||
|
sourcesCount = 0,
|
||||||
|
}: ProcessingIndicatorProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fade-in animation
|
||||||
|
setIsVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const steps: Array<{
|
||||||
|
key: ProcessingStep;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
emoji: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'collecting',
|
||||||
|
label: 'Coletando Dados',
|
||||||
|
icon: <Search className="w-4 h-4" />,
|
||||||
|
emoji: '🔍',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'processing',
|
||||||
|
label: 'Processando...',
|
||||||
|
icon: <Brain className="w-4 h-4" />,
|
||||||
|
emoji: '🤖',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'searching',
|
||||||
|
label: sourcesCount > 0 ? `Buscando na Web (${sourcesCount} fontes)...` : 'Buscando na Web...',
|
||||||
|
icon: <Globe className="w-4 h-4" />,
|
||||||
|
emoji: '🌐',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'preparing',
|
||||||
|
label: 'Preparando Contexto',
|
||||||
|
icon: <Package className="w-4 h-4" />,
|
||||||
|
emoji: '📦',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'streaming',
|
||||||
|
label: 'Gerando Resposta...',
|
||||||
|
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||||
|
emoji: '💬',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
|
||||||
|
const currentStepData = steps[currentStepIndex] || steps[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-4 mb-3 px-4 py-3 rounded-lg border border-muted/50 bg-muted/20 transition-all duration-300",
|
||||||
|
isVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Card de Status Atual */}
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="relative w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
{currentStep === 'collecting' && (
|
||||||
|
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-ping" />
|
||||||
|
)}
|
||||||
|
{currentStep === 'processing' && (
|
||||||
|
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-pulse" />
|
||||||
|
)}
|
||||||
|
{currentStep === 'searching' && (
|
||||||
|
<div className="absolute inset-0 rounded-full border-2 border-primary/30 animate-spin" style={{ animationDuration: '2s' }} />
|
||||||
|
)}
|
||||||
|
<span className="text-xl relative z-10">{currentStepData.emoji}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
{currentStepData.label}
|
||||||
|
</div>
|
||||||
|
{progress > 0 && progress < 100 && (
|
||||||
|
<div className="mt-1.5 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Visual */}
|
||||||
|
<div className="hidden md:flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
|
||||||
|
const isActive = idx === currentStepIndex;
|
||||||
|
const isPast = idx < currentStepIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.key} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-300",
|
||||||
|
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
|
||||||
|
isActive && "bg-primary/20 text-primary animate-pulse",
|
||||||
|
!isActive && !isPast && "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPast ? (
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
) : isActive ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
step.icon
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"transition-colors duration-300",
|
||||||
|
isActive && "text-foreground font-medium",
|
||||||
|
isPast && "text-muted-foreground opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.label.split(' ')[0]} {/* Mostrar apenas primeira palavra na timeline */}
|
||||||
|
</span>
|
||||||
|
{idx < currentStepIndex && (
|
||||||
|
<div className="w-6 h-px bg-muted mx-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Vertical para Mobile */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
|
||||||
|
const isActive = idx === currentStepIndex;
|
||||||
|
const isPast = idx < currentStepIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.key} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center w-5 h-5 rounded-full transition-all",
|
||||||
|
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
|
||||||
|
isActive && "bg-primary/20 text-primary animate-pulse",
|
||||||
|
!isActive && !isPast && "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPast ? (
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
) : isActive ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
step.icon
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs transition-colors",
|
||||||
|
isActive && "text-foreground font-medium",
|
||||||
|
isPast && "text-muted-foreground opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Sparkles, Loader2 } from "lucide-react";
|
import { Sparkles, Loader2 } from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { usePromptGenerator } from "@/hooks/use-prompt-generator";
|
import { usePromptGenerator } from "@/hooks/use-prompt-generator";
|
||||||
@@ -92,30 +93,54 @@ export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: Promp
|
|||||||
<label htmlFor="model-select" className="text-sm font-medium">
|
<label htmlFor="model-select" className="text-sm font-medium">
|
||||||
Modelo para Geração
|
Modelo para Geração
|
||||||
</label>
|
</label>
|
||||||
|
<TooltipProvider>
|
||||||
<Select
|
<Select
|
||||||
value={selectedModel || undefined}
|
value={selectedModel || undefined}
|
||||||
onValueChange={setSelectedModel}
|
onValueChange={setSelectedModel}
|
||||||
disabled={loading || models.length === 0}
|
disabled={loading || models.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="model-select">
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
|
||||||
<SelectValue placeholder={
|
<SelectValue placeholder={
|
||||||
loading
|
loading
|
||||||
? "Carregando modelos..."
|
? "Carregando modelos..."
|
||||||
: models.length === 0
|
: models.length === 0
|
||||||
? "Nenhum modelo disponível"
|
? "Nenhum modelo disponível"
|
||||||
: "Selecione um modelo..."
|
: "Selecione um modelo..."
|
||||||
} />
|
} className="truncate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
</TooltipTrigger>
|
||||||
|
{selectedModel && (
|
||||||
|
<TooltipContent side="top" className="max-w-[70vw] break-words">
|
||||||
|
<p className="text-sm">{selectedModel}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
<SelectContent className="max-w-[90vw]">
|
||||||
{models.length === 0 ? (
|
{models.length === 0 ? (
|
||||||
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
|
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
|
||||||
) : (
|
) : (
|
||||||
models.map(m => (
|
models.map(m => (
|
||||||
<SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>
|
<Tooltip key={m.name}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<SelectItem value={m.name} className="min-w-0">
|
||||||
|
<span className="truncate block" style={{ maxWidth: '70vw' }}>
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{m.name.length > 40 && (
|
||||||
|
<TooltipContent side="right" className="max-w-[70vw] break-words">
|
||||||
|
<p className="text-sm">{m.name}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</TooltipProvider>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
|
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
|
||||||
</p>
|
</p>
|
||||||
@@ -130,7 +155,7 @@ export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: Promp
|
|||||||
value={goal}
|
value={goal}
|
||||||
onChange={(e) => setGoal(e.target.value)}
|
onChange={(e) => setGoal(e.target.value)}
|
||||||
placeholder="Ex: Quero um assistente especialista em Python que explique conceitos complexos de forma simples..."
|
placeholder="Ex: Quero um assistente especialista em Python que explique conceitos complexos de forma simples..."
|
||||||
className="min-h-[150px]"
|
className="min-h-[160px]"
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
Search,
|
||||||
|
Globe,
|
||||||
|
Brain,
|
||||||
|
Package,
|
||||||
|
BookOpen
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ThinkingMessageMetadata } from '@/hooks/use-chat';
|
||||||
|
import { SourcesCompact } from './sources-compact';
|
||||||
|
|
||||||
|
interface ReasoningChainProps {
|
||||||
|
steps: ThinkingMessageMetadata[];
|
||||||
|
searchQueries?: string[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReasoningChain({ steps, searchQueries = [], className }: ReasoningChainProps) {
|
||||||
|
const hasActiveStep = steps.some(step => step.status === 'running');
|
||||||
|
const allSources = steps
|
||||||
|
.flatMap(step => step.sources || [])
|
||||||
|
.filter((source, index, self) =>
|
||||||
|
index === self.findIndex(s => s.url === source.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getStepIcon = (step: ThinkingMessageMetadata) => {
|
||||||
|
const isRunning = step.status === 'running';
|
||||||
|
const isCompleted = step.status === 'completed';
|
||||||
|
const isError = step.status === 'error';
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return null; // Não mostrar ícone para erros, apenas texto
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
return <CheckCircle2 className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
return <Loader2 className="w-3.5 h-3.5 text-muted-foreground/60 animate-spin" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ícone baseado no tipo de step
|
||||||
|
switch (step.stepType) {
|
||||||
|
case 'preprocessing':
|
||||||
|
return <Brain className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||||
|
case 'web-research':
|
||||||
|
return <Search className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||||
|
case 'sources-found':
|
||||||
|
return <Globe className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||||
|
case 'processing':
|
||||||
|
return <Brain className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||||
|
case 'response-generation':
|
||||||
|
return <Loader2 className="w-3.5 h-3.5 text-muted-foreground/60 animate-spin" />;
|
||||||
|
default:
|
||||||
|
return <Search className="w-3.5 h-3.5 text-muted-foreground/60" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("mb-6 space-y-3", className)}>
|
||||||
|
{/* Header: "Working..." com spinner sutil */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Circle className="w-2 h-2 fill-current animate-pulse" />
|
||||||
|
<span>Working...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista vertical de steps - texto simples */}
|
||||||
|
<div className="space-y-1.5 pl-6">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isRunning = step.status === 'running';
|
||||||
|
const isCompleted = step.status === 'completed';
|
||||||
|
const isError = step.status === 'error';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${step.stepType}-${step.timestamp}`}
|
||||||
|
className={cn(
|
||||||
|
"reasoning-step flex items-center gap-2 text-sm",
|
||||||
|
isError
|
||||||
|
? "text-destructive/80"
|
||||||
|
: isCompleted
|
||||||
|
? "text-muted-foreground/60"
|
||||||
|
: "text-muted-foreground/80"
|
||||||
|
)}
|
||||||
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
|
>
|
||||||
|
{getStepIcon(step)}
|
||||||
|
<span>{step.label}</span>
|
||||||
|
{step.details && (
|
||||||
|
<span className="text-xs text-muted-foreground/50 ml-1">
|
||||||
|
{step.details}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pills de busca - cápsulas sutis */}
|
||||||
|
{searchQueries.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 pl-6">
|
||||||
|
<span className="text-xs text-muted-foreground/60">Searching:</span>
|
||||||
|
{searchQueries.map((query, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="text-xs font-mono px-2 py-1 bg-white/5 dark:bg-white/5 border border-white/10 dark:border-white/10 rounded-full text-muted-foreground/70"
|
||||||
|
>
|
||||||
|
{query}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fontes em revisão */}
|
||||||
|
{allSources.length > 0 && (
|
||||||
|
<div className="pl-6">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs text-muted-foreground/60">
|
||||||
|
Reviewing sources - {allSources.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SourcesCompact sources={allSources} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { Search, BookOpen, CheckCircle2, AlertCircle, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ScrapedContent } from '@/services/webSearch';
|
||||||
|
import { SearchStatus } from '@/hooks/use-web-search';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SearchProgressProps {
|
||||||
|
status: SearchStatus;
|
||||||
|
query: string;
|
||||||
|
sources: ScrapedContent[];
|
||||||
|
error: string | null;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
onToggleExpand?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchProgress({
|
||||||
|
status,
|
||||||
|
query,
|
||||||
|
sources,
|
||||||
|
error,
|
||||||
|
isExpanded: controlledExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
}: SearchProgressProps) {
|
||||||
|
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||||
|
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
||||||
|
const toggleExpanded = onToggleExpand || (() => setInternalExpanded(!internalExpanded));
|
||||||
|
|
||||||
|
if (status === 'idle') return null;
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'searching':
|
||||||
|
return <Search className="w-3 h-3 text-muted-foreground" />;
|
||||||
|
case 'scraping':
|
||||||
|
return <BookOpen className="w-3 h-3 text-muted-foreground" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle2 className="w-3 h-3 text-muted-foreground" />;
|
||||||
|
case 'error':
|
||||||
|
return <AlertCircle className="w-3 h-3 text-destructive" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'searching':
|
||||||
|
return `Pesquisando: "${query}"`;
|
||||||
|
case 'scraping':
|
||||||
|
return `Lendo ${sources.length || '...'} fonte${sources.length !== 1 ? 's' : ''}`;
|
||||||
|
case 'completed':
|
||||||
|
return `✓ ${sources.length} fonte${sources.length !== 1 ? 's' : ''} consultada${sources.length !== 1 ? 's' : ''}`;
|
||||||
|
case 'error':
|
||||||
|
return `Erro: ${error || 'Falha ao buscar'}`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFaviconUrl = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = status === 'searching' || status === 'scraping';
|
||||||
|
const showExpandButton = status === 'completed' && sources.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"mx-6 mb-2 transition-all",
|
||||||
|
status === 'completed' && "opacity-70 hover:opacity-100",
|
||||||
|
status === 'error' && "text-destructive"
|
||||||
|
)}>
|
||||||
|
<div className="p-2 pl-3 border-l-2 border-muted flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{isActive ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<div className="w-3 h-3">
|
||||||
|
{getStatusIcon()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{getStatusText()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showExpandButton && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de fontes expandida */}
|
||||||
|
{isExpanded && sources.length > 0 && (
|
||||||
|
<div className="mt-2 ml-6 pl-3 border-l space-y-2">
|
||||||
|
{sources.map((source, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-start gap-2 p-2 rounded-md bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={getFaviconUrl(source.url)}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 rounded mt-0.5 flex-shrink-0"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<a
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-medium text-primary hover:underline line-clamp-1 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{source.title || source.url}
|
||||||
|
<ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{source.url}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { SessionSummary } from "@/hooks/use-chat-storage";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Trash2, Plus, MessageSquare, Search, X, Loader2, ChevronUp, ChevronDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface SidebarListProps {
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
currentSessionId: string | null;
|
||||||
|
onSelectSession: (id: string) => void;
|
||||||
|
onDeleteSession: (id: string) => void;
|
||||||
|
onNewChat: () => void;
|
||||||
|
onSearch?: (query: string) => void;
|
||||||
|
isSearching?: boolean;
|
||||||
|
searchQuery?: string;
|
||||||
|
onNavigateMatch?: (sessionId: string, direction: 'prev' | 'next') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInMs = now.getTime() - date.getTime();
|
||||||
|
const diffInHours = diffInMs / (1000 * 60 * 60);
|
||||||
|
const diffInDays = diffInMs / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
// Se foi hoje, mostrar apenas hora
|
||||||
|
if (diffInHours < 24) {
|
||||||
|
return new Intl.DateTimeFormat('pt-BR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se foi ontem
|
||||||
|
if (diffInDays < 2) {
|
||||||
|
return 'Ontem';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se foi nos últimos 7 dias
|
||||||
|
if (diffInDays < 7) {
|
||||||
|
return new Intl.DateTimeFormat('pt-BR', {
|
||||||
|
weekday: 'short'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caso contrário, mostrar data completa
|
||||||
|
return new Intl.DateTimeFormat('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: diffInDays > 365 ? 'numeric' : undefined
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarList({
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
onSelectSession,
|
||||||
|
onDeleteSession,
|
||||||
|
onNewChat,
|
||||||
|
onSearch,
|
||||||
|
isSearching = false,
|
||||||
|
searchQuery = '',
|
||||||
|
onNavigateMatch
|
||||||
|
}: SidebarListProps) {
|
||||||
|
const [localSearchInput, setLocalSearchInput] = useState('');
|
||||||
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Limpar timeout anterior
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se query vazia, limpar busca imediatamente
|
||||||
|
if (!localSearchInput.trim()) {
|
||||||
|
if (onSearch) {
|
||||||
|
onSearch('');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: aguardar antes de buscar
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (onSearch && localSearchInput.trim().length >= 2) {
|
||||||
|
onSearch(localSearchInput.trim());
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [localSearchInput, onSearch]);
|
||||||
|
|
||||||
|
const handleClearSearch = () => {
|
||||||
|
setLocalSearchInput('');
|
||||||
|
if (onSearch) {
|
||||||
|
onSearch('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<div className="flex flex-col h-full border-r border-sidebar-border bg-sidebar w-full overflow-x-hidden">
|
||||||
|
{/* Header com busca e botão Nova Conversa */}
|
||||||
|
<div className="p-3 border-b border-sidebar-border flex-shrink-0 space-y-2.5">
|
||||||
|
{/* Campo de busca */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar conversas..."
|
||||||
|
value={localSearchInput}
|
||||||
|
onChange={(e) => setLocalSearchInput(e.target.value)}
|
||||||
|
className="pl-8 pr-8 h-9 text-sm"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground animate-spin" />
|
||||||
|
)}
|
||||||
|
{localSearchInput && !isSearching && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Limpar busca"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão Nova Conversa */}
|
||||||
|
<Button
|
||||||
|
onClick={onNewChat}
|
||||||
|
className="w-full justify-start gap-2 text-sm font-medium h-9"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 shrink-0" />
|
||||||
|
<span className="truncate">Nova Conversa</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de chats com scroll customizado */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 sidebar-chat-list">
|
||||||
|
<div className="p-2 space-y-1.5">
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
|
||||||
|
<MessageSquare className="w-10 h-10 text-muted-foreground/40 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{searchQuery ? 'Nenhuma conversa encontrada' : 'Nenhuma conversa salva'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
{searchQuery
|
||||||
|
? 'Tente buscar por outro termo'
|
||||||
|
: 'Comece uma nova conversa para começar'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const isSelected = currentSessionId === session.id;
|
||||||
|
const showTooltip = session.title.length > 30;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip key={session.id} delayDuration={300} disableHoverableContent>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"sidebar-chat-item group relative bg-card border rounded-lg cursor-pointer transition-all duration-200",
|
||||||
|
"hover:shadow-md hover:border-primary/20 hover:-translate-y-0.5",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
isSelected && "border-primary/40 shadow-sm bg-accent/50",
|
||||||
|
"active:scale-[0.98]"
|
||||||
|
)}
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelectSession(session.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Conteúdo do card */}
|
||||||
|
<div className="sidebar-chat-item-content p-2.5">
|
||||||
|
{/* Ícone/Emoji */}
|
||||||
|
<div className="shrink-0 flex items-center justify-center w-8 h-8 rounded-md bg-muted/50 text-base">
|
||||||
|
{session.emoji || '💬'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Texto do card */}
|
||||||
|
<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">
|
||||||
|
{session.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="sidebar-chat-item-date text-xs text-muted-foreground">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* 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 */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-7 w-7 shrink-0 transition-all duration-200",
|
||||||
|
"opacity-0 group-hover:opacity-100",
|
||||||
|
"hover:bg-destructive/10 hover:text-destructive",
|
||||||
|
"active:scale-95"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onDeleteSession(session.id);
|
||||||
|
}}
|
||||||
|
aria-label={`Deletar conversa "${session.title}"`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indicador de seleção */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary rounded-l-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{showTooltip && (
|
||||||
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
|
<p className="break-words font-medium">{session.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{formatDate(session.updated_at)}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import { ScrapedContent } from '@/services/webSearch';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SourcesCarouselProps {
|
||||||
|
sources: ScrapedContent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourcesCarousel({ sources }: SourcesCarouselProps) {
|
||||||
|
if (!sources || sources.length === 0) return null;
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [startX, setStartX] = useState(0);
|
||||||
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
|
const [hasMoved, setHasMoved] = useState(false);
|
||||||
|
|
||||||
|
// Handlers para mouse drag
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
if (!scrollContainerRef.current) return;
|
||||||
|
setIsDragging(true);
|
||||||
|
setStartX(e.pageX - scrollContainerRef.current.offsetLeft);
|
||||||
|
setScrollLeft(scrollContainerRef.current.scrollLeft);
|
||||||
|
setHasMoved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
if (!isDragging || !scrollContainerRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const x = e.pageX - scrollContainerRef.current.offsetLeft;
|
||||||
|
const walk = (x - startX) * 1.5; // Velocidade do scroll
|
||||||
|
scrollContainerRef.current.scrollLeft = scrollLeft - walk;
|
||||||
|
|
||||||
|
// Se moveu mais de 3px, marca como movimento (não clique)
|
||||||
|
if (Math.abs(walk) > 3) {
|
||||||
|
setHasMoved(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
// Resetar após um pequeno delay
|
||||||
|
setTimeout(() => setHasMoved(false), 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
setTimeout(() => setHasMoved(false), 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handlers para touch drag (mobile)
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
if (!scrollContainerRef.current) return;
|
||||||
|
setIsDragging(true);
|
||||||
|
setStartX(e.touches[0].pageX - scrollContainerRef.current.offsetLeft);
|
||||||
|
setScrollLeft(scrollContainerRef.current.scrollLeft);
|
||||||
|
setHasMoved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
if (!isDragging || !scrollContainerRef.current) return;
|
||||||
|
const x = e.touches[0].pageX - scrollContainerRef.current.offsetLeft;
|
||||||
|
const walk = (x - startX) * 1.5;
|
||||||
|
scrollContainerRef.current.scrollLeft = scrollLeft - walk;
|
||||||
|
|
||||||
|
if (Math.abs(walk) > 3) {
|
||||||
|
setHasMoved(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
setTimeout(() => setHasMoved(false), 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevenir navegação do link durante drag
|
||||||
|
const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (hasMoved || isDragging) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-3xl mx-auto mb-4 mt-2">
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 px-1">
|
||||||
|
Fontes Analisadas
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
className={cn(
|
||||||
|
"flex gap-3 overflow-x-auto pb-4 scrollbar-hide -mx-4 px-4 sm:mx-0 sm:px-0 mask-fade-sides",
|
||||||
|
"cursor-grab active:cursor-grabbing select-none touch-pan-x",
|
||||||
|
isDragging && "cursor-grabbing"
|
||||||
|
)}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
style={{
|
||||||
|
scrollBehavior: isDragging ? 'auto' : 'smooth',
|
||||||
|
WebkitOverflowScrolling: 'touch' // Smooth scrolling no iOS
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sources.map((source, idx) => {
|
||||||
|
const hostname = new URL(source.url).hostname.replace('www.', '');
|
||||||
|
const faviconUrl = `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
className="flex-none w-40 group transition-all hover:-translate-y-1 block"
|
||||||
|
style={{ pointerEvents: (isDragging || hasMoved) ? 'none' : 'auto' }}
|
||||||
|
>
|
||||||
|
<Card className="h-24 p-3 flex flex-col justify-between hover:shadow-md hover:border-primary/20 transition-colors bg-card/50 backdrop-blur-sm cursor-pointer overflow-hidden">
|
||||||
|
<div className="flex items-start justify-between min-h-0 flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 rounded-sm opacity-70 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||||
|
onError={(e) => (e.target as HTMLImageElement).style.display = 'none'}
|
||||||
|
/>
|
||||||
|
<ExternalLink className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 ml-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 min-w-0 flex-1 overflow-hidden flex flex-col justify-end">
|
||||||
|
<div className="text-xs font-medium leading-tight line-clamp-2 text-card-foreground/90 group-hover:text-primary transition-colors break-words overflow-hidden text-ellipsis">
|
||||||
|
{source.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate overflow-hidden text-ellipsis">
|
||||||
|
{hostname}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Source {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourcesCompactProps {
|
||||||
|
sources: Source[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourcesCompact({ sources, className }: SourcesCompactProps) {
|
||||||
|
const getFaviconUrl = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDomain = (url: string): string => {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!sources || sources.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex gap-3 overflow-x-auto py-2 sources-scrollbar", className)}>
|
||||||
|
{sources.map((source, idx) => {
|
||||||
|
const faviconUrl = getFaviconUrl(source.url);
|
||||||
|
const domain = getDomain(source.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 border border-transparent hover:border-muted transition-all flex-shrink-0"
|
||||||
|
>
|
||||||
|
{faviconUrl ? (
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 rounded flex-shrink-0"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ExternalLink className="w-4 h-4 text-muted-foreground/60 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-medium truncate max-w-[140px] text-foreground/90">
|
||||||
|
{source.title || domain}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{domain}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ExternalLink, Copy, Check, Globe } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import type { ScrapedContent } from '@/services/webSearch';
|
||||||
|
|
||||||
|
interface SourcesDisplayProps {
|
||||||
|
sources: ScrapedContent[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourcesDisplay({ sources, className }: SourcesDisplayProps) {
|
||||||
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (!sources || sources.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async (url: string, index: number) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopiedIndex(index);
|
||||||
|
setTimeout(() => setCopiedIndex(null), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy URL:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFaviconUrl = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDomain = (url: string): string => {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("px-6 py-4 border-t border-muted/50", className)}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Globe className="w-4 h-4 text-muted-foreground/60" />
|
||||||
|
<p className="text-xs font-medium text-muted-foreground/80">
|
||||||
|
Fontes consultadas ({sources.length}):
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 overflow-x-auto py-2 sources-scrollbar">
|
||||||
|
{sources.map((source, idx) => {
|
||||||
|
const faviconUrl = getFaviconUrl(source.url);
|
||||||
|
const domain = getDomain(source.url);
|
||||||
|
const isCopied = copiedIndex === idx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/30 hover:bg-muted/50 border border-transparent hover:border-muted transition-all flex-shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
// Permitir que o botão de copiar funcione sem navegar
|
||||||
|
if ((e.target as HTMLElement).closest('button')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{faviconUrl ? (
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 rounded flex-shrink-0"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Globe className="w-4 h-4 text-muted-foreground/60 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-medium truncate max-w-[140px] text-foreground/90">
|
||||||
|
{source.title || domain}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{domain}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(source.url, idx);
|
||||||
|
}}
|
||||||
|
className="ml-1 p-1 rounded hover:bg-muted/50 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title="Copiar URL"
|
||||||
|
aria-label="Copiar URL"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2, Sparkles } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface StreamingIndicatorProps {
|
||||||
|
isStreaming: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamingIndicator({ isStreaming, className }: StreamingIndicatorProps) {
|
||||||
|
const [dots, setDots] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStreaming) {
|
||||||
|
setDots('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDots(prev => {
|
||||||
|
if (prev === '...') return '';
|
||||||
|
return prev + '.';
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isStreaming]);
|
||||||
|
|
||||||
|
if (!isStreaming) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-2 text-xs text-muted-foreground px-6 py-2", className)}>
|
||||||
|
<div className="relative">
|
||||||
|
<Sparkles className="w-3 h-3 text-primary animate-pulse" />
|
||||||
|
<div className="absolute inset-0 bg-primary/20 rounded-full animate-ping" />
|
||||||
|
</div>
|
||||||
|
<span>Streaming{dots}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Trash2, HardDrive, Cpu, Database } from "lucide-react";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Trash2, HardDrive, Cpu, Database, Upload, Globe } from "lucide-react";
|
||||||
import { useSystemMonitor } from "@/hooks/use-system-monitor";
|
import { useSystemMonitor } from "@/hooks/use-system-monitor";
|
||||||
import { useLocalModels } from "@/hooks/use-local-models";
|
import { useLocalModels } from "@/hooks/use-local-models";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ModelDownloadDialog } from "./model-download-dialog";
|
||||||
|
|
||||||
export function SystemPanel() {
|
export function SystemPanel() {
|
||||||
const stats = useSystemMonitor();
|
const stats = useSystemMonitor();
|
||||||
const { models, deleteModel, refresh } = useLocalModels();
|
const { models, deleteModel, refresh } = useLocalModels();
|
||||||
|
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
||||||
|
|
||||||
const ramUsagePercent = stats
|
const ramUsagePercent = stats
|
||||||
? (stats.memory_used / stats.memory_total) * 100
|
? (stats.memory_used / stats.memory_total) * 100
|
||||||
@@ -63,43 +68,106 @@ export function SystemPanel() {
|
|||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Modelos Instalados
|
Modelos Instalados
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDownloadDialog(true)}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
>
|
||||||
|
<Upload className="w-3 h-3 mr-1" />
|
||||||
|
Adicionar
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
|
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
|
||||||
Atualizar
|
Atualizar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{models.map((model) => (
|
{models.map((model) => {
|
||||||
<Card key={model.id} className="overflow-hidden">
|
// Tentar detectar se é modelo local/GGUF (heurística: modelos GGUF geralmente têm nomes específicos)
|
||||||
<CardContent className="p-3 flex items-center justify-between">
|
// Por enquanto, vamos mostrar todos como disponíveis
|
||||||
<div className="min-w-0">
|
const isLocal = model.name.toLowerCase().includes('gguf') ||
|
||||||
|
model.name.toLowerCase().endsWith('.gguf');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={model.id} className="overflow-hidden hover:bg-muted/50 transition-colors">
|
||||||
|
<CardContent className="p-3 flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<div className="font-medium text-sm truncate" title={model.name}>
|
<div className="font-medium text-sm truncate" title={model.name}>
|
||||||
{model.name}
|
{model.name}
|
||||||
</div>
|
</div>
|
||||||
|
{isLocal && (
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
|
||||||
|
<Upload className="w-2.5 h-2.5 mr-1" />
|
||||||
|
Local
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
<Database className="w-3 h-3" />
|
<Database className="w-3 h-3" />
|
||||||
{model.size}
|
{model.size}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 flex-shrink-0"
|
||||||
onClick={() => deleteModel(model.name)}
|
onClick={async () => {
|
||||||
|
if (confirm(`Tem certeza que deseja remover o modelo "${model.name}"?`)) {
|
||||||
|
try {
|
||||||
|
await deleteModel(model.name);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao remover modelo:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Remover modelo</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{models.length === 0 && (
|
{models.length === 0 && (
|
||||||
<div className="text-center text-sm text-muted-foreground py-4">
|
<div className="text-center text-sm text-muted-foreground py-8 space-y-2">
|
||||||
Nenhum modelo encontrado.
|
<p>Nenhum modelo encontrado.</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDownloadDialog(true)}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
Instalar Modelo
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ModelDownloadDialog
|
||||||
|
open={showDownloadDialog}
|
||||||
|
onOpenChange={setShowDownloadDialog}
|
||||||
|
onSuccess={(modelName) => {
|
||||||
|
refresh();
|
||||||
|
setShowDownloadDialog(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Search, BookOpen, Brain, Loader2, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type ThinkingStep =
|
||||||
|
| 'analyzing'
|
||||||
|
| 'planning'
|
||||||
|
| 'searching'
|
||||||
|
| 'reading'
|
||||||
|
| 'validating'
|
||||||
|
| 'formulating'
|
||||||
|
| 'complete';
|
||||||
|
|
||||||
|
interface ThinkingIndicatorProps {
|
||||||
|
currentStep: ThinkingStep;
|
||||||
|
searchQuery?: string;
|
||||||
|
sourcesRead?: number;
|
||||||
|
totalSources?: number;
|
||||||
|
planSize?: number;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThinkingIndicator({
|
||||||
|
currentStep,
|
||||||
|
searchQuery,
|
||||||
|
sourcesRead = 0,
|
||||||
|
totalSources = 0,
|
||||||
|
planSize = 0,
|
||||||
|
}: ThinkingIndicatorProps) {
|
||||||
|
const steps: Array<{
|
||||||
|
key: ThinkingStep;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'analyzing',
|
||||||
|
label: 'Analisando...',
|
||||||
|
icon: <Brain className="w-3 h-3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'planning',
|
||||||
|
label: 'Planejando pesquisa...',
|
||||||
|
icon: <Brain className="w-3 h-3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'searching',
|
||||||
|
label: planSize > 0 ? `Pesquisando (${planSize} tópicos)...` : 'Pesquisando na web...',
|
||||||
|
icon: <Search className="w-3 h-3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reading',
|
||||||
|
label: 'Lendo conteúdo...',
|
||||||
|
icon: <BookOpen className="w-3 h-3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'validating',
|
||||||
|
label: 'Validando dados...',
|
||||||
|
icon: <CheckCircle2 className="w-3 h-3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'formulating',
|
||||||
|
label: 'Formulando resposta...',
|
||||||
|
icon: <Brain className="w-3 h-3" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentStepIndex = steps.findIndex(s => s.key === currentStep);
|
||||||
|
const isComplete = currentStep === 'complete';
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
return null; // Não mostrar quando completo
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-4 border-b border-muted/50 bg-muted/20">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
{steps.slice(0, currentStepIndex + 1).map((step, idx) => {
|
||||||
|
const isActive = idx === currentStepIndex;
|
||||||
|
const isPast = idx < currentStepIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.key} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center w-5 h-5 rounded-full transition-colors",
|
||||||
|
isPast && "bg-green-500/20 text-green-600 dark:text-green-400",
|
||||||
|
isActive && "bg-blue-500/20 text-blue-600 dark:text-blue-400 animate-pulse",
|
||||||
|
!isActive && !isPast && "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPast ? (
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
) : isActive ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
step.icon
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
isActive && "text-foreground font-medium",
|
||||||
|
isPast && "text-muted-foreground line-through opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
{idx < steps.length - 1 && (
|
||||||
|
<div className="w-8 h-px bg-muted mx-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { Search, BookOpen, CheckCircle2, AlertCircle, ExternalLink, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ScrapedContent } from '@/services/webSearch';
|
||||||
|
import { SearchStatus } from '@/hooks/use-web-search';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
interface WebSearchStatusProps {
|
||||||
|
status: SearchStatus;
|
||||||
|
query: string;
|
||||||
|
sources: ScrapedContent[];
|
||||||
|
error: string | null;
|
||||||
|
onViewSources?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebSearchStatus({
|
||||||
|
status,
|
||||||
|
query,
|
||||||
|
sources,
|
||||||
|
error,
|
||||||
|
}: WebSearchStatusProps) {
|
||||||
|
const [isSourcesOpen, setIsSourcesOpen] = useState(false);
|
||||||
|
|
||||||
|
if (status === 'idle') return null;
|
||||||
|
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'searching':
|
||||||
|
return <Search className="w-4 h-4 animate-pulse" />;
|
||||||
|
case 'scraping':
|
||||||
|
return <BookOpen className="w-4 h-4 animate-pulse" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||||
|
case 'error':
|
||||||
|
return <AlertCircle className="w-4 h-4 text-red-500" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'searching':
|
||||||
|
return `🔍 Pesquisando por: "${query}"...`;
|
||||||
|
case 'scraping':
|
||||||
|
return `📖 Lendo ${sources.length || '...'} fonte${sources.length !== 1 ? 's' : ''}...`;
|
||||||
|
case 'completed':
|
||||||
|
return `✅ ${sources.length} fonte${sources.length !== 1 ? 's' : ''} encontrada${sources.length !== 1 ? 's' : ''}`;
|
||||||
|
case 'error':
|
||||||
|
return `❌ Erro: ${error || 'Falha ao buscar'}`;
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFaviconUrl = (url: string): string => {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=16`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className={cn(
|
||||||
|
"mx-4 mb-2 border-l-4",
|
||||||
|
status === 'completed' && "border-l-green-500",
|
||||||
|
status === 'error' && "border-l-red-500",
|
||||||
|
(status === 'searching' || status === 'scraping') && "border-l-blue-500"
|
||||||
|
)}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{status === 'searching' || status === 'scraping' ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||||
|
) : (
|
||||||
|
getStatusIcon()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium truncate">
|
||||||
|
{getStatusText()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'completed' && sources.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{/* Favicons das fontes */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{sources.slice(0, 3).map((source, idx) => (
|
||||||
|
<img
|
||||||
|
key={idx}
|
||||||
|
src={getFaviconUrl(source.url)}
|
||||||
|
alt=""
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{sources.length > 3 && (
|
||||||
|
<span className="text-xs text-muted-foreground">+{sources.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isSourcesOpen} onOpenChange={setIsSourcesOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs">
|
||||||
|
Ver Fontes
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Fontes Encontradas</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{sources.length} fonte{sources.length !== 1 ? 's' : ''} analisada{sources.length !== 1 ? 's' : ''} para: "{query}"
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 pr-2">
|
||||||
|
{sources.map((source, idx) => (
|
||||||
|
<Card key={idx} className="border">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-semibold text-sm mb-1 line-clamp-2">
|
||||||
|
{source.title}
|
||||||
|
</h4>
|
||||||
|
<a
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-muted-foreground hover:text-primary flex items-center gap-1 truncate"
|
||||||
|
>
|
||||||
|
{source.url}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="prose dark:prose-invert prose-sm max-w-none max-h-[300px] overflow-y-auto">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{source.markdown}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -7,7 +10,12 @@ import {
|
|||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Download } from "lucide-react"
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
import { Download, Loader2, AlertCircle, CheckCircle2, Play } from "lucide-react"
|
||||||
|
import { useOllamaDownload } from "@/hooks/use-ollama-download"
|
||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import { useOperatingSystem } from "@/hooks/use-operating-system"
|
||||||
|
import type { OS } from "@/lib/download-utils"
|
||||||
|
|
||||||
interface InstallModalProps {
|
interface InstallModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -15,6 +23,162 @@ interface InstallModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||||
|
const { os: detectedOS } = useOperatingSystem();
|
||||||
|
const [activeTab, setActiveTab] = useState<OS>('mac');
|
||||||
|
const downloadState = useOllamaDownload();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Detectar OS e definir aba ativa quando modal abrir
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && detectedOS) {
|
||||||
|
setActiveTab(detectedOS);
|
||||||
|
}
|
||||||
|
}, [open, detectedOS]);
|
||||||
|
|
||||||
|
// Resetar estado quando o modal abrir
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
downloadState.reset();
|
||||||
|
// Verificar se já existe download para a tab ativa
|
||||||
|
downloadState.checkExistingDownload(activeTab);
|
||||||
|
} else {
|
||||||
|
// Parar verificação automática quando modal fechar
|
||||||
|
downloadState.stopAutoCheck();
|
||||||
|
}
|
||||||
|
}, [open, activeTab]);
|
||||||
|
|
||||||
|
// Verificar download existente ao mudar de tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
downloadState.checkExistingDownload(activeTab);
|
||||||
|
}
|
||||||
|
}, [activeTab, open]);
|
||||||
|
|
||||||
|
// Escutar evento de Ollama instalado
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const handleOllamaInstalled = () => {
|
||||||
|
// Ollama foi encontrado, verificar novamente para atualizar o estado
|
||||||
|
onCheckAgain();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('ollama-installed', handleOllamaInstalled);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('ollama-installed', handleOllamaInstalled);
|
||||||
|
};
|
||||||
|
}, [open, onCheckAgain]);
|
||||||
|
|
||||||
|
// Mostrar toast quando houver erro
|
||||||
|
useEffect(() => {
|
||||||
|
if (downloadState.downloadError) {
|
||||||
|
toast({
|
||||||
|
title: 'Erro no download',
|
||||||
|
description: downloadState.downloadError,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [downloadState.downloadError, toast]);
|
||||||
|
|
||||||
|
const handleDownload = async (os: OS) => {
|
||||||
|
await downloadState.handleDownload(os);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
try {
|
||||||
|
await downloadState.handleInstall();
|
||||||
|
toast({
|
||||||
|
title: 'Instalador iniciado',
|
||||||
|
description: 'O instalador foi aberto. Siga as instruções na tela.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Erro ao executar instalador',
|
||||||
|
description: error instanceof Error ? error.message : 'Erro desconhecido',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DownloadButton = ({ os, label }: { os: OS; label: string }) => {
|
||||||
|
const isActive = activeTab === os;
|
||||||
|
const isDownloading = downloadState.isDownloading && isActive;
|
||||||
|
const hasDownloaded = downloadState.filePath !== null && isActive;
|
||||||
|
|
||||||
|
if (hasDownloaded && !isDownloading) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleInstall}
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Instalar Ollama
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleDownload(os)}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{downloadState.downloadStatus || 'Baixando...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DownloadProgress = ({ os }: { os: OS }) => {
|
||||||
|
const isActive = activeTab === os;
|
||||||
|
const isDownloading = downloadState.isDownloading && isActive;
|
||||||
|
const hasDownloaded = downloadState.filePath !== null && isActive;
|
||||||
|
|
||||||
|
if (!isDownloading && !hasDownloaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isDownloading && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{downloadState.downloadStatus || 'Baixando...'}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{downloadState.downloadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={downloadState.downloadProgress} className="h-2" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasDownloaded && !isDownloading && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-md">
|
||||||
|
<CheckCircle2 className="h-4 w-4 mt-0.5 text-green-600 dark:text-green-400" />
|
||||||
|
<div className="text-sm flex-1">
|
||||||
|
<p className="font-medium text-green-900 dark:text-green-100">
|
||||||
|
Download concluído
|
||||||
|
</p>
|
||||||
|
<p className="text-green-700 dark:text-green-300">
|
||||||
|
Clique em "Instalar Ollama" para iniciar a instalação.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open}>
|
<Dialog open={open}>
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
@@ -22,29 +186,41 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
<DialogTitle>Ollama não encontrado</DialogTitle>
|
<DialogTitle>Ollama não encontrado</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
|
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
|
||||||
Siga os passos abaixo para o seu sistema operacional.
|
Baixe e instale o Ollama usando os passos abaixo.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs defaultValue="mac" className="w-full">
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as OS)} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="mac">macOS</TabsTrigger>
|
<TabsTrigger value="mac">macOS</TabsTrigger>
|
||||||
<TabsTrigger value="linux">Linux</TabsTrigger>
|
<TabsTrigger value="linux">Linux</TabsTrigger>
|
||||||
<TabsTrigger value="windows">Windows</TabsTrigger>
|
<TabsTrigger value="windows">Windows</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="mac" className="space-y-4 py-4">
|
<TabsContent value="mac" className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-medium">1. Download e Instalação</h3>
|
<h3 className="font-medium">1. Download e Instalação</h3>
|
||||||
<Button variant="outline" className="w-full" asChild>
|
<DownloadButton os="mac" label="Baixar para macOS" />
|
||||||
<a href="https://ollama.com/download/Ollama-darwin.zip" target="_blank" rel="noopener noreferrer">
|
<DownloadProgress os="mac" />
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Baixar para macOS
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
{downloadState.downloadError && activeTab === 'mac' && (
|
||||||
Após instalar, abra o terminal e digite <code>ollama serve</code> se necessário.
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
|
||||||
|
<p className="text-red-700 dark:text-red-300">
|
||||||
|
{downloadState.downloadError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>Após instalar, o Ollama será iniciado automaticamente. Se necessário, abra o terminal e digite <code className="bg-muted px-1 rounded">ollama serve</code>.</p>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="linux" className="space-y-4 py-4">
|
<TabsContent value="linux" className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-medium">Instalação via Terminal</h3>
|
<h3 className="font-medium">Instalação via Terminal</h3>
|
||||||
@@ -52,30 +228,84 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
<code className="text-sm break-all">curl -fsSL https://ollama.com/install.sh | sh</code>
|
<code className="text-sm break-all">curl -fsSL https://ollama.com/install.sh | sh</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium">Ou baixar e instalar via interface</h3>
|
||||||
|
<DownloadButton os="linux" label="Baixar Script de Instalação" />
|
||||||
|
<DownloadProgress os="linux" />
|
||||||
|
</div>
|
||||||
|
{downloadState.downloadError && activeTab === 'linux' && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
|
||||||
|
<p className="text-red-700 dark:text-red-300">
|
||||||
|
{downloadState.downloadError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-medium">Comando Manual</h3>
|
<h3 className="font-medium">Comando Manual</h3>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Ou consulte <a href="https://ollama.com/download/linux" className="underline text-primary" target="_blank" rel="noopener noreferrer">ollama.com/download/linux</a> para instruções manuais.
|
Ou consulte <a href="https://ollama.com/download/linux" className="underline text-primary" target="_blank" rel="noopener noreferrer">ollama.com/download/linux</a> para instruções manuais.
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="windows" className="space-y-4 py-4">
|
<TabsContent value="windows" className="space-y-4 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-medium">1. Download do Instalador</h3>
|
<h3 className="font-medium">1. Download do Instalador</h3>
|
||||||
<Button variant="outline" className="w-full" asChild>
|
<DownloadButton os="windows" label="Baixar para Windows" />
|
||||||
<a href="https://ollama.com/download/OllamaSetup.exe" target="_blank" rel="noopener noreferrer">
|
<DownloadProgress os="windows" />
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Baixar para Windows
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
{downloadState.downloadError && activeTab === 'windows' && (
|
||||||
Execute o instalador baixado e siga as instruções na tela.
|
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-950/20 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 text-red-600 dark:text-red-400" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-red-900 dark:text-red-100">Erro no download</p>
|
||||||
|
<p className="text-red-700 dark:text-red-300">
|
||||||
|
{downloadState.downloadError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>Após clicar em "Instalar Ollama", o instalador será aberto. Siga as instruções na tela para completar a instalação.</p>
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
💡 Dica: Após concluir a instalação, você pode fechar qualquer janela de chat aberta pelo Ollama e continuar aqui no OllaHub.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Status de verificação automática */}
|
||||||
|
{downloadState.isChecking && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-md mt-4">
|
||||||
|
<Loader2 className="h-4 w-4 mt-0.5 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||||
|
<div className="text-sm flex-1">
|
||||||
|
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||||
|
Verificando instalação do Ollama...
|
||||||
|
</p>
|
||||||
|
<p className="text-blue-700 dark:text-blue-300">
|
||||||
|
{downloadState.checkStatus || 'Aguardando instalação ser concluída...'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||||
|
Esta verificação continuará em segundo plano até encontrar o Ollama.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
<Button onClick={onCheckAgain}>
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
// Chamar verificação completa (instalação + execução)
|
||||||
|
onCheckAgain();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Verificar Novamente
|
Verificar Novamente
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,4 +313,3 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Heart } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function WelcomeNotification() {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
// Verificar se já foi mostrado
|
||||||
|
const welcomeShown = localStorage.getItem('ollahub_welcome_shown');
|
||||||
|
if (!welcomeShown) {
|
||||||
|
setShow(true);
|
||||||
|
// Marcar como mostrado
|
||||||
|
localStorage.setItem('ollahub_welcome_shown', 'true');
|
||||||
|
// Auto-fechar após 5 segundos
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShow(false);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted || !show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed top-4 left-1/2 -translate-x-1/2 z-50 w-full max-w-md",
|
||||||
|
"animate-in slide-in-from-top-5 fade-in duration-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="bg-primary text-primary-foreground rounded-lg shadow-lg p-4 mx-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Heart className="h-5 w-5 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-sm mb-1">
|
||||||
|
Obrigado por usar o OllaHub! 🎉
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-primary-foreground/90">
|
||||||
|
Estamos felizes em tê-lo conosco. Comece a explorar e aproveite sua experiência!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-primary-foreground hover:bg-primary-foreground/20 flex-shrink-0"
|
||||||
|
onClick={() => setShow(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePythonBootstrap } from '@/hooks/use-python-bootstrap';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Loader2, XCircle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
checking: 'Verificando',
|
||||||
|
downloading: 'Baixando',
|
||||||
|
extracting: 'Extraindo',
|
||||||
|
creating_venv: 'Criando ambiente virtual',
|
||||||
|
installing_pip: 'Instalando pip',
|
||||||
|
copying_scripts: 'Copiando scripts',
|
||||||
|
installing_deps: 'Instalando dependências',
|
||||||
|
validating: 'Validando',
|
||||||
|
done: 'Concluído',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PythonBootstrapProgress() {
|
||||||
|
const bootstrap = usePythonBootstrap();
|
||||||
|
|
||||||
|
// Se estiver completo e sem erro, não mostrar nada
|
||||||
|
if (bootstrap.isComplete && !bootstrap.error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se houver erro, mostrar alerta
|
||||||
|
if (bootstrap.error) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md mx-4">
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<XCircle className="h-5 w-5" />
|
||||||
|
Erro no Bootstrap do Python
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
O aplicativo não pode funcionar sem o runtime Python
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3">
|
||||||
|
<p className="text-sm text-destructive font-medium">{bootstrap.error}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Por favor, reinicie o aplicativo ou verifique os logs para mais detalhes.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mostrar progresso durante bootstrap
|
||||||
|
const stageLabel = STAGE_LABELS[bootstrap.stage] || bootstrap.stage;
|
||||||
|
const progressValue = bootstrap.progress ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md mx-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
|
Inicializando Python Runtime
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configurando ambiente Python para scraping web
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">{stageLabel}</span>
|
||||||
|
{bootstrap.progress !== null && (
|
||||||
|
<span className="text-muted-foreground">{Math.round(progressValue * 100)}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bootstrap.progress !== null && (
|
||||||
|
<Progress value={progressValue * 100} className="h-2" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{bootstrap.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Loader2, Search, FileText } from "lucide-react";
|
||||||
|
import { useWebSearch } from "@/hooks/use-web-search";
|
||||||
|
|
||||||
|
export function ScrapingProgressIndicator() {
|
||||||
|
const webSearch = useWebSearch();
|
||||||
|
|
||||||
|
// Não mostrar se idle ou completed
|
||||||
|
if (webSearch.status === 'idle' || webSearch.status === 'completed') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
className="fixed bottom-24 right-6 bg-card border border-primary/20 rounded-lg p-4 shadow-lg backdrop-blur-sm z-50 min-w-[280px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Icon animado */}
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{webSearch.status === 'searching' && (
|
||||||
|
<Search className="w-5 h-5 text-primary animate-pulse" />
|
||||||
|
)}
|
||||||
|
{webSearch.status === 'scraping' && (
|
||||||
|
<FileText className="w-5 h-5 text-primary animate-pulse" />
|
||||||
|
)}
|
||||||
|
{webSearch.status === 'error' && (
|
||||||
|
<div className="w-5 h-5 rounded-full bg-destructive/20 flex items-center justify-center">
|
||||||
|
<span className="text-destructive text-xs font-bold">!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{webSearch.status === 'searching' && 'Buscando fontes...'}
|
||||||
|
{webSearch.status === 'scraping' && `Analisando fontes (${webSearch.scrapedSources.length})...`}
|
||||||
|
{webSearch.status === 'error' && 'Erro na busca'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mostrar query atual se disponível */}
|
||||||
|
{webSearch.currentQuery && webSearch.status !== 'error' && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||||
|
{webSearch.currentQuery}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mostrar erro se houver */}
|
||||||
|
{webSearch.error && webSearch.status === 'error' && (
|
||||||
|
<p className="text-xs text-destructive line-clamp-2">
|
||||||
|
{webSearch.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Barra de progresso simulada */}
|
||||||
|
{webSearch.status !== 'error' && (
|
||||||
|
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="h-full bg-primary"
|
||||||
|
initial={{ width: "0%" }}
|
||||||
|
animate={{
|
||||||
|
width: webSearch.status === 'searching' ? "40%" : "80%"
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Monitor, Thermometer, Zap, Fan, Cpu, HardDrive, Activity } from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
|
interface GpuStats {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
vendor: string | null;
|
||||||
|
vram_used_mb: number | null;
|
||||||
|
vram_total_mb: number | null;
|
||||||
|
vram_percent: number | null;
|
||||||
|
compute_usage_percent: number | null;
|
||||||
|
graphics_usage_percent: number | null;
|
||||||
|
overall_usage_percent: number | null;
|
||||||
|
temperature_celsius: number | null;
|
||||||
|
temperature_max_celsius: number | null;
|
||||||
|
power_watts: number | null;
|
||||||
|
power_max_watts: number | null;
|
||||||
|
fan_speed_rpm: number | null;
|
||||||
|
fan_speed_percent: number | null;
|
||||||
|
processes_count: number | null;
|
||||||
|
driver_version: string | null;
|
||||||
|
api: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpuDashboardProps {
|
||||||
|
gpuId?: string;
|
||||||
|
pollInterval?: number; // em milissegundos, padrão 2000ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GpuDashboard({ gpuId, pollInterval = 2000 }: GpuDashboardProps) {
|
||||||
|
const [stats, setStats] = useState<GpuStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<GpuStats | null>('get_gpu_stats', { gpuId: gpuId || null });
|
||||||
|
setStats(data);
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load GPU stats:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Erro ao carregar estatísticas da GPU');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
const interval = setInterval(loadStats, pollInterval);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [gpuId, pollInterval]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
Carregando estatísticas da GPU...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !stats) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
{error || 'GPU não detectada ou sem suporte para monitoramento detalhado'}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (mb: number | null): string => {
|
||||||
|
if (!mb) return '-';
|
||||||
|
if (mb < 1024) return `${mb} MB`;
|
||||||
|
return `${(mb / 1024).toFixed(2)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (value: number | null, thresholds: { good: number; warning: number }): string => {
|
||||||
|
if (!value) return 'bg-muted';
|
||||||
|
if (value < thresholds.good) return 'bg-green-500';
|
||||||
|
if (value < thresholds.warning) return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTemperatureColor = (temp: number | null): string => {
|
||||||
|
if (!temp) return 'text-muted-foreground';
|
||||||
|
if (temp < 60) return 'text-green-500';
|
||||||
|
if (temp < 80) return 'text-yellow-500';
|
||||||
|
return 'text-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Card Principal da GPU */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Monitor className="w-4 h-4" />
|
||||||
|
GPU
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{stats.vendor && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{stats.vendor}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{stats.api && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{stats.api}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 truncate" title={stats.name}>
|
||||||
|
{stats.name}
|
||||||
|
</p>
|
||||||
|
{stats.driver_version && (
|
||||||
|
<p className="text-xs text-muted-foreground">Driver: {stats.driver_version}</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* VRAM */}
|
||||||
|
{stats.vram_total_mb && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<HardDrive className="w-3 h-3" />
|
||||||
|
Memória VRAM
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatBytes(stats.vram_used_mb)} / {formatBytes(stats.vram_total_mb)}
|
||||||
|
{stats.vram_percent && ` (${stats.vram_percent.toFixed(1)}%)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{stats.vram_percent !== null && (
|
||||||
|
<Progress
|
||||||
|
value={stats.vram_percent}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Uso de Processamento */}
|
||||||
|
{stats.overall_usage_percent !== null && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Cpu className="w-3 h-3" />
|
||||||
|
Uso de Processamento
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{stats.overall_usage_percent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={stats.overall_usage_percent}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
{stats.compute_usage_percent !== null && stats.graphics_usage_percent !== null && (
|
||||||
|
<div className="flex gap-2 text-[10px] text-muted-foreground">
|
||||||
|
<span>Compute: {stats.compute_usage_percent.toFixed(1)}%</span>
|
||||||
|
<span>Graphics: {stats.graphics_usage_percent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid de Métricas */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-2">
|
||||||
|
{/* Temperatura */}
|
||||||
|
{stats.temperature_celsius !== null && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Thermometer className="w-3 h-3" />
|
||||||
|
Temperatura
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className={`text-lg font-semibold ${getTemperatureColor(stats.temperature_celsius)}`}>
|
||||||
|
{stats.temperature_celsius.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">°C</span>
|
||||||
|
{stats.temperature_max_celsius && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
/ {stats.temperature_max_celsius.toFixed(0)}°C
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Energia */}
|
||||||
|
{stats.power_watts !== null && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Energia
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{stats.power_watts.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">W</span>
|
||||||
|
{stats.power_max_watts && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
/ {stats.power_max_watts.toFixed(1)}W
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ventilador */}
|
||||||
|
{(stats.fan_speed_percent !== null || stats.fan_speed_rpm !== null) && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Fan className="w-3 h-3" />
|
||||||
|
Ventilador
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
{stats.fan_speed_percent !== null && (
|
||||||
|
<>
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{stats.fan_speed_percent.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">%</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{stats.fan_speed_rpm !== null && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-1">
|
||||||
|
({stats.fan_speed_rpm} RPM)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Processos */}
|
||||||
|
{stats.processes_count !== null && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Activity className="w-3 h-3" />
|
||||||
|
Processos
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-lg font-semibold">
|
||||||
|
{stats.processes_count}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{stats.processes_count === 1 ? 'processo' : 'processos'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Cpu, MemoryStick, HardDrive, Activity, Clock, Monitor } from 'lucide-react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { GpuDashboard } from './GpuDashboard';
|
||||||
|
|
||||||
|
interface SystemStats {
|
||||||
|
cpu_usage: number;
|
||||||
|
ram_used: number;
|
||||||
|
ram_total: number;
|
||||||
|
ram_percent: number;
|
||||||
|
gpu_name: string | null;
|
||||||
|
uptime: number;
|
||||||
|
processes_count: number;
|
||||||
|
cpu_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HardwareDashboard() {
|
||||||
|
const [stats, setStats] = useState<SystemStats | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const data = await invoke<SystemStats>('get_system_stats');
|
||||||
|
setStats(data);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load system stats:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
const interval = setInterval(loadStats, 1000); // Atualiza a cada 1 segundo
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading || !stats) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
Carregando estatísticas do sistema...
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUptime = (seconds: number): string => {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
} else {
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCpuColor = (usage: number): string => {
|
||||||
|
if (usage < 50) return 'bg-green-500';
|
||||||
|
if (usage < 80) return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRamColor = (percent: number): string => {
|
||||||
|
if (percent < 60) return 'bg-blue-500';
|
||||||
|
if (percent < 85) return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{/* CPU Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Cpu className="w-4 h-4" />
|
||||||
|
CPU
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{stats.cpu_usage.toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Progress
|
||||||
|
value={stats.cpu_usage}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">{stats.cpu_name}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* RAM Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<MemoryStick className="w-4 h-4" />
|
||||||
|
Memória RAM
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{stats.ram_percent.toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Progress
|
||||||
|
value={stats.ram_percent}
|
||||||
|
className="h-2"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{formatBytes(stats.ram_used)} / {formatBytes(stats.ram_total)}</span>
|
||||||
|
<span>{formatBytes(stats.ram_total - stats.ram_used)} livre</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* System Info Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
Sistema
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-2">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Uptime
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{formatUptime(stats.uptime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Processos</span>
|
||||||
|
<span className="font-medium">{stats.processes_count.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Visual RAM Usage */}
|
||||||
|
<Card className="md:col-span-2 lg:col-span-1">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<HardDrive className="w-4 h-4" />
|
||||||
|
Uso de Memória
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Usado</span>
|
||||||
|
<span className="font-medium">{stats.ram_percent.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-24 bg-muted rounded-lg overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-0 left-0 right-0 ${getRamColor(stats.ram_percent)} transition-all duration-300`}
|
||||||
|
style={{ height: `${stats.ram_percent}%` }}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-xs font-medium">
|
||||||
|
{formatBytes(stats.ram_used)} / {formatBytes(stats.ram_total)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>0%</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GPU Dashboard - Substitui o card simples */}
|
||||||
|
<GpuDashboard pollInterval={2000} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, KeyboardEvent } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
interface DomainTagsInputProps {
|
||||||
|
domains: string[];
|
||||||
|
onAdd: (domain: string) => void;
|
||||||
|
onRemove: (domain: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DomainTagsInput({
|
||||||
|
domains,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
placeholder = 'Digite um domínio e pressione Enter...',
|
||||||
|
}: DomainTagsInputProps) {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && inputValue.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
onAdd(inputValue.trim());
|
||||||
|
setInputValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDomain = (domain: string): string => {
|
||||||
|
return domain
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/^https?:\/\//, '')
|
||||||
|
.replace(/\/$/, '')
|
||||||
|
.split('/')[0]; // Remove path
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Domínios Bloqueados</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 p-3 border rounded-md min-h-[50px] bg-background">
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<Badge
|
||||||
|
key={domain}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex items-center gap-1 pr-1"
|
||||||
|
>
|
||||||
|
{domain}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(domain)}
|
||||||
|
className="ml-1 rounded-full hover:bg-destructive/20 p-0.5 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={domains.length === 0 ? placeholder : ''}
|
||||||
|
className="flex-1 min-w-[200px] border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-auto p-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Digite um domínio (ex: youtube.com) e pressione Enter para adicionar à lista negra.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Check, Cpu, Monitor, Zap, Laptop } from "lucide-react";
|
||||||
|
import { GpuInfo } from "@/lib/recommendation";
|
||||||
|
import { useSettingsStore } from "@/store/settings-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface GpuSelectorProps {
|
||||||
|
gpus: GpuInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GpuSelector({ gpus }: GpuSelectorProps) {
|
||||||
|
const selectedGpu = useSettingsStore((state) => state.selectedGpu);
|
||||||
|
const setSelectedGpu = useSettingsStore((state) => state.setSelectedGpu);
|
||||||
|
|
||||||
|
// Se não há GPUs ou apenas uma, não mostrar seletor
|
||||||
|
if (gpus.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se há apenas uma GPU, selecionar automaticamente
|
||||||
|
if (gpus.length === 1 && !selectedGpu) {
|
||||||
|
setSelectedGpu(gpus[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selecionar primeira GPU por padrão se nenhuma estiver selecionada
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedGpu && gpus.length > 0) {
|
||||||
|
setSelectedGpu(gpus[0].id);
|
||||||
|
}
|
||||||
|
}, [selectedGpu, gpus, setSelectedGpu]);
|
||||||
|
|
||||||
|
const getVendorIcon = (vendor: string | null) => {
|
||||||
|
const iconClass = "h-5 w-5";
|
||||||
|
switch (vendor?.toUpperCase()) {
|
||||||
|
case 'NVIDIA':
|
||||||
|
return <Zap className={cn(iconClass, "text-green-500")} />;
|
||||||
|
case 'AMD':
|
||||||
|
return <Zap className={cn(iconClass, "text-red-500")} />;
|
||||||
|
case 'INTEL':
|
||||||
|
return <Cpu className={cn(iconClass, "text-blue-500")} />;
|
||||||
|
case 'APPLE':
|
||||||
|
return <Monitor className={cn(iconClass, "text-gray-500")} />;
|
||||||
|
default:
|
||||||
|
return <Monitor className={cn(iconClass, "text-muted-foreground")} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatMemory = (memoryMb: number | null) => {
|
||||||
|
if (!memoryMb) return null;
|
||||||
|
if (memoryMb >= 1024) {
|
||||||
|
return `${(memoryMb / 1024).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
return `${memoryMb} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-2xl mx-auto bg-muted/30 animate-in fade-in slide-in-from-bottom-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
|
<Monitor className="text-primary" />
|
||||||
|
Placas de Vídeo Detectadas
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Selecione qual GPU você deseja usar para processamento de IA
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{gpus.map((gpu) => {
|
||||||
|
const isSelected = selectedGpu === gpu.id;
|
||||||
|
const memoryStr = formatMemory(gpu.memory_mb);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={gpu.id}
|
||||||
|
onClick={() => setSelectedGpu(gpu.id)}
|
||||||
|
className={cn(
|
||||||
|
"relative p-4 rounded-lg border-2 transition-all text-left",
|
||||||
|
"hover:border-primary/50 hover:bg-accent/50",
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/10 shadow-md"
|
||||||
|
: "border-border bg-background"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<Check className="h-4 w-4 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="mt-0.5">
|
||||||
|
{getVendorIcon(gpu.vendor)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-sm truncate">{gpu.name}</h3>
|
||||||
|
{gpu.vendor && (
|
||||||
|
<Badge variant="outline" className="mt-1 text-xs">
|
||||||
|
{gpu.vendor}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{memoryStr && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Cpu className="h-3 w-3" />
|
||||||
|
<span>{memoryStr} VRAM</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!memoryStr && gpu.vendor && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{gpu.vendor === 'Intel' ? 'GPU Integrada' : 'GPU Dedicada'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gpus.length === 1 && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-4 text-center">
|
||||||
|
Apenas uma GPU foi detectada e será usada automaticamente.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,12 +14,22 @@ interface ModelSelectorProps {
|
|||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DownloadInfo {
|
||||||
|
status: string;
|
||||||
|
percent?: number;
|
||||||
|
downloaded?: string;
|
||||||
|
total?: string;
|
||||||
|
speed?: string;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps) {
|
export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps) {
|
||||||
const [selectedModelId, setSelectedModelId] = useState(recommendation.modelId);
|
const [selectedModelId, setSelectedModelId] = useState(recommendation.modelId);
|
||||||
const [isInstalled, setIsInstalled] = useState(false);
|
const [isInstalled, setIsInstalled] = useState(false);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||||
const [downloadStatus, setDownloadStatus] = useState("");
|
const [downloadStatus, setDownloadStatus] = useState("");
|
||||||
|
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
|
||||||
|
|
||||||
const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0];
|
const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0];
|
||||||
|
|
||||||
@@ -29,25 +39,60 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlisten = listen<string>('download-progress', (event) => {
|
const unlisten = listen<string>('download-progress', (event) => {
|
||||||
const line = event.payload;
|
const payload = event.payload;
|
||||||
setDownloadStatus(line);
|
|
||||||
|
|
||||||
// Simple heuristic parsing for progress
|
// Tentar parsear como JSON estruturado
|
||||||
// Ollama output example: "pulling manifest", "downloading 10%", "verifying sha256 digest"
|
let info: DownloadInfo | null = null;
|
||||||
if (line.includes('%')) {
|
try {
|
||||||
const match = line.match(/(\d+)%/);
|
info = JSON.parse(payload) as DownloadInfo;
|
||||||
if (match) {
|
} catch {
|
||||||
setDownloadProgress(parseInt(match[1], 10));
|
// Fallback: tratar como string raw
|
||||||
|
info = {
|
||||||
|
status: "downloading",
|
||||||
|
raw: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info) {
|
||||||
|
setDownloadInfo(info);
|
||||||
|
|
||||||
|
// Atualizar status text
|
||||||
|
const statusText = info.status === "pulling" ? "Baixando modelo..." :
|
||||||
|
info.status === "verifying" ? "Verificando..." :
|
||||||
|
info.status === "writing" ? "Salvando..." :
|
||||||
|
info.status === "success" ? "Concluído!" :
|
||||||
|
"Baixando...";
|
||||||
|
setDownloadStatus(statusText);
|
||||||
|
|
||||||
|
// Atualizar progresso
|
||||||
|
if (info.percent !== undefined) {
|
||||||
|
setDownloadProgress(info.percent);
|
||||||
|
} else {
|
||||||
|
// Fallback: estimar progresso baseado no status
|
||||||
|
if (info.status === "pulling") {
|
||||||
|
setDownloadProgress(prev => prev < 10 ? 10 : prev);
|
||||||
|
} else if (info.status === "verifying") {
|
||||||
|
setDownloadProgress(90);
|
||||||
|
} else if (info.status === "writing") {
|
||||||
|
setDownloadProgress(95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se chegou a 100% ou success, marcar como instalado após um pequeno delay
|
||||||
|
if (info.percent === 100 || info.status === "success") {
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsInstalled(true);
|
||||||
|
setIsDownloading(false);
|
||||||
|
checkInstallation(selectedModelId);
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
} else if (line.includes('success')) {
|
|
||||||
setDownloadProgress(100);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unlisten.then(f => f());
|
unlisten.then(f => f());
|
||||||
};
|
};
|
||||||
}, []);
|
}, [selectedModelId]);
|
||||||
|
|
||||||
const checkInstallation = async (modelId: string) => {
|
const checkInstallation = async (modelId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -61,13 +106,15 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
|
|||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setDownloadProgress(0);
|
setDownloadProgress(0);
|
||||||
|
setDownloadStatus("Iniciando download...");
|
||||||
try {
|
try {
|
||||||
await invoke('pull_model', { name: selectedModelId });
|
await invoke('pull_model', { name: selectedModelId });
|
||||||
setIsInstalled(true);
|
// O status será atualizado pelo listener quando o download completar
|
||||||
setIsDownloading(false);
|
// Não precisamos setar aqui pois o listener já faz isso
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed", error);
|
console.error("Download failed", error);
|
||||||
setDownloadStatus("Falha no download. Tente novamente.");
|
setDownloadStatus("Falha no download. Tente novamente.");
|
||||||
|
setDownloadProgress(0);
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -128,6 +175,18 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
|
|||||||
<span>{downloadProgress}%</span>
|
<span>{downloadProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={downloadProgress} className="h-2" />
|
<Progress value={downloadProgress} className="h-2" />
|
||||||
|
{downloadInfo && (downloadInfo.downloaded || downloadInfo.speed) && (
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{downloadInfo.downloaded && downloadInfo.total
|
||||||
|
? `${downloadInfo.downloaded} / ${downloadInfo.total}`
|
||||||
|
: downloadInfo.downloaded || ""}
|
||||||
|
</span>
|
||||||
|
{downloadInfo.speed && (
|
||||||
|
<span>{downloadInfo.speed}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ export function ThemeProvider({
|
|||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
|
import { Minus, Square, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function TitleBar() {
|
||||||
|
const [isMaximized, setIsMaximized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const checkMaximized = async () => {
|
||||||
|
try {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
const maximized = await appWindow.isMaximized();
|
||||||
|
if (isMounted) {
|
||||||
|
setIsMaximized(maximized);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check window state:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkMaximized();
|
||||||
|
|
||||||
|
// Listener para mudanças de estado
|
||||||
|
const setupListener = async () => {
|
||||||
|
try {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
const unlisten = await appWindow.onResized(async () => {
|
||||||
|
if (isMounted) {
|
||||||
|
const maximized = await appWindow.isMaximized();
|
||||||
|
setIsMaximized(maximized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unlisten;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to setup resize listener:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let unlistenFn: (() => void) | null = null;
|
||||||
|
setupListener().then(fn => {
|
||||||
|
unlistenFn = fn;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
if (unlistenFn) {
|
||||||
|
unlistenFn();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMinimize = async () => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
await appWindow.minimize();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMaximize = async () => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
if (isMaximized) {
|
||||||
|
await appWindow.unmaximize();
|
||||||
|
} else {
|
||||||
|
await appWindow.maximize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = async () => {
|
||||||
|
const appWindow = getCurrentWindow();
|
||||||
|
await appWindow.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-8 bg-sidebar border-b border-sidebar-border flex items-center justify-between select-none z-50 shrink-0"
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
{/* Área de arrasto com logo/título */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 h-full flex-1"
|
||||||
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-sidebar-foreground">
|
||||||
|
OllaHub
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controles de janela */}
|
||||||
|
<div className="flex items-center h-full">
|
||||||
|
<button
|
||||||
|
onClick={handleMinimize}
|
||||||
|
className="h-full w-12 flex items-center justify-center hover:bg-sidebar-accent transition-colors"
|
||||||
|
title="Minimizar"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4 text-sidebar-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMaximize}
|
||||||
|
className="h-full w-12 flex items-center justify-center hover:bg-sidebar-accent transition-colors"
|
||||||
|
title={isMaximized ? "Restaurar" : "Maximizar"}
|
||||||
|
>
|
||||||
|
<Square className={cn(
|
||||||
|
"w-3.5 h-3.5 text-sidebar-foreground",
|
||||||
|
isMaximized && "opacity-60"
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="h-full w-12 flex items-center justify-center hover:bg-destructive transition-colors"
|
||||||
|
title="Fechar"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-sidebar-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -21,12 +21,12 @@ const buttonVariants = cva(
|
|||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-10 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-12 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: "size-9",
|
icon: "size-10",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "size-8",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-12",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -197,3 +197,5 @@ export {
|
|||||||
DropdownMenuRadioGroup,
|
DropdownMenuRadioGroup,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
|
|
||||||
@@ -25,3 +25,5 @@ Progress.displayName = ProgressPrimitive.Root.displayName
|
|||||||
|
|
||||||
export { Progress }
|
export { Progress }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Padding = "sm" | "md" | "lg";
|
||||||
|
|
||||||
|
interface SectionProps extends React.ComponentProps<"section"> {
|
||||||
|
padding?: Padding;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paddingToClass(padding?: Padding) {
|
||||||
|
switch (padding) {
|
||||||
|
case "sm": return "px-4 md:px-6 lg:px-8 py-4 md:py-6";
|
||||||
|
case "lg": return "px-4 md:px-6 lg:px-8 py-8 md:py-12";
|
||||||
|
case "md":
|
||||||
|
default:
|
||||||
|
return "px-4 md:px-6 lg:px-8 py-6 md:py-8";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ padding = "md", fullWidth = false, className, children, ...props }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(paddingToClass(padding), fullWidth ? "w-full" : "w-full max-w-6xl mx-auto", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 [&>span]:truncate [&>span]:min-w-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -157,3 +157,5 @@ export {
|
|||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
))
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Gap = 8 | 16 | 24 | 32 | 40 | 48;
|
||||||
|
type Direction = "vertical" | "horizontal";
|
||||||
|
type Align = "start" | "center" | "end" | "stretch";
|
||||||
|
type Justify = "start" | "center" | "end" | "between";
|
||||||
|
|
||||||
|
interface StackProps extends React.ComponentProps<"div"> {
|
||||||
|
direction?: Direction;
|
||||||
|
gap?: Gap;
|
||||||
|
align?: Align;
|
||||||
|
justify?: Justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gapToClass(gap?: Gap) {
|
||||||
|
switch (gap) {
|
||||||
|
case 8: return "gap-2";
|
||||||
|
case 16: return "gap-4";
|
||||||
|
case 24: return "gap-6";
|
||||||
|
case 32: return "gap-8";
|
||||||
|
case 40: return "gap-10";
|
||||||
|
case 48: return "gap-12";
|
||||||
|
default: return "gap-4";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignToClass(align?: Align) {
|
||||||
|
switch (align) {
|
||||||
|
case "start": return "items-start";
|
||||||
|
case "center": return "items-center";
|
||||||
|
case "end": return "items-end";
|
||||||
|
case "stretch": return "items-stretch";
|
||||||
|
default: return "items-start";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function justifyToClass(justify?: Justify) {
|
||||||
|
switch (justify) {
|
||||||
|
case "start": return "justify-start";
|
||||||
|
case "center": return "justify-center";
|
||||||
|
case "end": return "justify-end";
|
||||||
|
case "between": return "justify-between";
|
||||||
|
default: return "justify-start";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stack({
|
||||||
|
direction = "vertical",
|
||||||
|
gap = 16,
|
||||||
|
align = "start",
|
||||||
|
justify = "start",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: StackProps) {
|
||||||
|
const dirClass = direction === "vertical" ? "flex flex-col" : "flex";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(dirClass, gapToClass(gap), alignToClass(align), justifyToClass(justify), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
|
|
||||||
@@ -21,3 +21,5 @@ Textarea.displayName = "Textarea"
|
|||||||
|
|
||||||
export { Textarea }
|
export { Textarea }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export const BATCH_VERIFY_PROMPTS = {
|
||||||
|
DECOMPOSITION: `Role: Research Lead.
|
||||||
|
Task: Decompose the user's query into 3-5 atomic, search-friendly questions to cover different angles.
|
||||||
|
Output: JSON array of strings ONLY. No markdown, no explanations.
|
||||||
|
|
||||||
|
User Query: "{{userQuery}}"
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
1. PRESERVE THE SUBJECT: If the user asks about "Garuva", ALL queries must mention "Garuva".
|
||||||
|
2. DO NOT TRANSLATE proper names or specific terms unless necessary.
|
||||||
|
3. AVOID generic queries like "what is this" without context.
|
||||||
|
4. Return ONLY a JSON array of strings.
|
||||||
|
|
||||||
|
Example Output for "Onde fica Garuva?":
|
||||||
|
["localização garuva sc", "mapa garuva santa catarina", "história cidade garuva"]`,
|
||||||
|
|
||||||
|
VALIDATION: `Role: Fact Checker.
|
||||||
|
Task: Analyze the following search results from multiple sources. Identify consensus and discrepancies.
|
||||||
|
|
||||||
|
Search Results:
|
||||||
|
{{searchResults}}
|
||||||
|
|
||||||
|
Output structure:
|
||||||
|
1. Consensus: Facts agreed upon by multiple sources.
|
||||||
|
2. Discrepancies: Conflicts between sources (cite sources).
|
||||||
|
3. Reliability: Which sources seem most authoritative?
|
||||||
|
4. Verdict: Can we answer the user's query with high confidence? (YES/NO/PARTIAL)
|
||||||
|
|
||||||
|
Keep it concise and factual.`,
|
||||||
|
|
||||||
|
SYNTHESIS: `Role: Senior Analyst.
|
||||||
|
Task: Generate a final response based on the validation report and search results.
|
||||||
|
|
||||||
|
Validation Report:
|
||||||
|
{{validationReport}}
|
||||||
|
|
||||||
|
Original Query: "{{userQuery}}"
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
1. Veredito Direto: The consolidated answer.
|
||||||
|
2. Evidências Cruzadas: A comparison table or list showing consensus.
|
||||||
|
3. Fontes: List key URLs used.
|
||||||
|
4. If data is insufficient/conflicting, state it clearly.
|
||||||
|
|
||||||
|
Language: Portuguese (Brazil).`
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
export const DEEP_RESEARCH_PROMPTS = {
|
||||||
|
DECOMPOSITION: `Role: Research Lead specializing in web search optimization.
|
||||||
|
Task: Decompose the user's query into 3-5 atomic, search-friendly questions optimized for web search engines.
|
||||||
|
|
||||||
|
User Query: "{{userQuery}}"
|
||||||
|
|
||||||
|
## CRITICAL INSTRUCTIONS
|
||||||
|
|
||||||
|
1. **PRESERVE THE SUBJECT**: If the user asks about "Garuva", ALL queries must mention "Garuva".
|
||||||
|
2. **USE SPECIFIC KEYWORDS**: Include relevant technical terms, proper names, locations, dates, or context that will help search engines find precise results.
|
||||||
|
3. **AVOID GENERIC QUERIES**: Never create queries like "what is this" or "tell me about" without specific context. Instead, use descriptive terms.
|
||||||
|
4. **OPTIMIZE FOR SEARCH ENGINES**:
|
||||||
|
- Use terms that are likely to appear in search results
|
||||||
|
- Include synonyms or related terms when appropriate
|
||||||
|
- For academic/research queries, include terms like "estudo", "pesquisa", "resultado", "premiado"
|
||||||
|
- For location queries, include geographic context (state, country, region)
|
||||||
|
5. **COVER DIFFERENT ANGLES**: Each query should explore a different aspect or perspective of the original question.
|
||||||
|
6. **DO NOT TRANSLATE**: Preserve proper names, technical terms, and specific terminology in their original form.
|
||||||
|
7. **OUTPUT FORMAT**: Return ONLY a JSON array of strings. No markdown, no explanations, no additional text.
|
||||||
|
|
||||||
|
## EXAMPLES
|
||||||
|
|
||||||
|
Input: "Onde fica Garuva?"
|
||||||
|
Output: ["localização garuva sc", "mapa garuva santa catarina", "história cidade garuva"]
|
||||||
|
|
||||||
|
Input: "quais os ultimos resultados academicos mais premiados?"
|
||||||
|
Output: ["resultados academicos premiados 2024", "pesquisas cientificas premiadas recentes", "estudos academicos reconhecidos premios", "descobertas academicas premiadas"]
|
||||||
|
|
||||||
|
Input: "como funciona inteligencia artificial?"
|
||||||
|
Output: ["inteligencia artificial como funciona", "IA machine learning algoritmos", "sistemas inteligencia artificial aplicacoes"]
|
||||||
|
|
||||||
|
## YOUR TASK
|
||||||
|
|
||||||
|
Decompose this query into search-optimized sub-queries:
|
||||||
|
"{{userQuery}}"
|
||||||
|
|
||||||
|
Return ONLY a JSON array of strings:`,
|
||||||
|
|
||||||
|
CONTEXT_CHECK: `Role: Fact Checker.
|
||||||
|
Task: Analyze the Knowledge Base below and determine if it contains sufficient information to answer the user's query.
|
||||||
|
|
||||||
|
## KNOWLEDGE BASE
|
||||||
|
{{knowledgeBase}}
|
||||||
|
|
||||||
|
## USER QUERY
|
||||||
|
"{{userQuery}}"
|
||||||
|
|
||||||
|
## OUTPUT
|
||||||
|
1. **Sufficiency**: Does the Knowledge Base contain enough information to answer the query? (YES/NO/PARTIAL)
|
||||||
|
2. **Key Facts Found**: List the main facts relevant to the query (bullet points).
|
||||||
|
3. **Missing Information**: What information is NOT in the Knowledge Base but would be needed?
|
||||||
|
4. **Reliability Assessment**: Rate the overall reliability of sources (HIGH/MEDIUM/LOW).
|
||||||
|
|
||||||
|
Be concise and factual.`,
|
||||||
|
|
||||||
|
STRICT_GENERATION: `Role: Rigorous Fact-Based Reporter.
|
||||||
|
|
||||||
|
## CRITICAL INSTRUCTIONS
|
||||||
|
You are a reporter who can ONLY write what is explicitly stated in the KNOWLEDGE BASE below.
|
||||||
|
- EVERY claim MUST be supported by the Knowledge Base.
|
||||||
|
- Use citations in the format [ID: X] after each fact.
|
||||||
|
- If a fact is NOT in the Knowledge Base, DO NOT WRITE IT.
|
||||||
|
- If you cannot answer the question from the Knowledge Base, say: "Não encontrei informações suficientes nas fontes consultadas para responder completamente."
|
||||||
|
- DO NOT use your internal knowledge. Pretend you know NOTHING except what is in the Knowledge Base.
|
||||||
|
- Language: Portuguese (Brazil).
|
||||||
|
|
||||||
|
## KNOWLEDGE BASE (Your ONLY source of truth)
|
||||||
|
{{knowledgeBase}}
|
||||||
|
|
||||||
|
## VALIDATION REPORT
|
||||||
|
{{validationReport}}
|
||||||
|
|
||||||
|
## USER QUERY
|
||||||
|
"{{userQuery}}"
|
||||||
|
|
||||||
|
## YOUR RESPONSE
|
||||||
|
Write a comprehensive answer using ONLY the Knowledge Base above. Cite sources using [ID: X].`,
|
||||||
|
|
||||||
|
CITATION_FORMAT: `
|
||||||
|
When citing sources, use this format:
|
||||||
|
- For inline citations: "Garuva está localizada em Santa Catarina [ID: 1]."
|
||||||
|
- For multiple sources: "A cidade possui praias e montanhas [ID: 1, 3]."
|
||||||
|
- At the end, list all sources used with their URLs.
|
||||||
|
`,
|
||||||
|
|
||||||
|
QUERY_ENRICHMENT: `Você é um especialista em otimização de queries de busca e expansão semântica.
|
||||||
|
|
||||||
|
Com base na query original e no contexto analisado, gere múltiplas variantes de queries enriquecidas para maximizar a cobertura de resultados relevantes.
|
||||||
|
|
||||||
|
## QUERY ORIGINAL
|
||||||
|
"{{originalQuery}}"
|
||||||
|
|
||||||
|
## CONTEXTO ANALISADO
|
||||||
|
- Intenção: {{intent}}
|
||||||
|
- Tópicos: {{topics}}
|
||||||
|
- Entidades: {{entities}}
|
||||||
|
- Contexto Temporal: {{temporalContext}}
|
||||||
|
- Contexto Geográfico: {{geographicContext}}
|
||||||
|
- Palavras-chave: {{keywords}}
|
||||||
|
- Sinônimos: {{synonyms}}
|
||||||
|
- Domínio: {{domain}}
|
||||||
|
|
||||||
|
## INSTRUÇÕES
|
||||||
|
|
||||||
|
Gere queries enriquecidas nas seguintes categorias:
|
||||||
|
|
||||||
|
1. **LITERAL**: Variações diretas da query original (preservar termos principais)
|
||||||
|
2. **SEMANTIC**: Variantes semânticas usando sinônimos e reformulações
|
||||||
|
3. **RELATED**: Perguntas relacionadas e FAQs
|
||||||
|
4. **EXPANDED**: Expansões temáticas específicas
|
||||||
|
5. **CONTEXTUAL**: Queries com contexto adicional (datas, locais, domínios)
|
||||||
|
|
||||||
|
## REGRAS CRÍTICAS
|
||||||
|
|
||||||
|
- **PRESERVAR ENTIDADES**: Se a query menciona uma entidade, TODAS as queries devem mencioná-la
|
||||||
|
- **NÃO TRADUZIR**: Preservar nomes próprios, termos técnicos na forma original
|
||||||
|
- **OTIMIZAR PARA BUSCA**: Usar termos que aparecem em resultados reais
|
||||||
|
- **COBERTURA AMPLA**: Cada categoria deve explorar diferentes ângulos
|
||||||
|
- **QUALIDADE > QUANTIDADE**: 3-5 queries por categoria é suficiente
|
||||||
|
|
||||||
|
Retorne APENAS um JSON válido com arrays para cada categoria.`
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,29 +1,54 @@
|
|||||||
Você é um assistente de IA avançado integrado ao OllaHub. Sua principal função é fornecer respostas úteis, precisas e bem formatadas.
|
Você é o OllaHub AI, um assistente especializado em análise detalhada e factual.
|
||||||
|
|
||||||
## DIRETRIZES DE FORMATAÇÃO (MUITO IMPORTANTE)
|
## REGRAS ABSOLUTAS
|
||||||
|
|
||||||
Para garantir a melhor experiência de leitura, siga rigorosamente estas regras de Markdown:
|
1. **Nunca** escreva tags `<metadata>`, JSON oculto ou textos como "Metadados:". Toda resposta deve ser apenas texto visível ao usuário.
|
||||||
|
2. Use Markdown limpo: títulos, listas e blocos de código com linguagem indicada.
|
||||||
|
3. Comandos e códigos devem estar em blocos triplos (```bash, ```json, etc.).
|
||||||
|
4. Se receber um bloco [CONTEXTO WEB], use essas informações prioritariamente para responder.
|
||||||
|
|
||||||
### 1. Estrutura e Hierarquia
|
## DIRETRIZES DE RESPOSTA (RAG)
|
||||||
- Use **Títulos (H1, H2, H3)** para organizar seções. Nunca use apenas negrito para títulos.
|
|
||||||
- Use **Listas (Bulleted ou Numbered)** para passos, itens ou características.
|
|
||||||
- Use **Separadores (---)** para dividir grandes blocos de conteúdo.
|
|
||||||
|
|
||||||
### 2. Blocos de Código
|
### 1. Contextualização Temporal Obrigatória
|
||||||
- SEMPRE use blocos de código triplos (\`\`\`) para qualquer trecho de código, comando de terminal ou configuração.
|
|
||||||
- SEMPRE especifique a linguagem após os acentos graves (ex: \`\`\`python, \`\`\`bash, \`\`\`json).
|
|
||||||
- Para menções curtas de código no meio do texto, use `código inline`.
|
|
||||||
|
|
||||||
### 3. Ênfase e Destaque
|
- **SEMPRE** inicie respostas sobre notícias, eventos ou fatos atuais citando a data atual explicitamente.
|
||||||
- Use **Negrito** para conceitos chave ou avisos importantes.
|
- Exemplo: "Com base nas informações de hoje, [DATA], os principais destaques são..."
|
||||||
- Use *Itálico* para termos estrangeiros ou ênfase sutil.
|
- Use a data fornecida no prompt como referência absoluta para "hoje", "ontem", "amanhã".
|
||||||
- Use > Blockquotes para citações ou notas laterais.
|
|
||||||
|
|
||||||
### 4. Links
|
### 2. Densidade e Profundidade
|
||||||
- Formate links corretamente: `[Texto do Link](URL)`.
|
|
||||||
|
|
||||||
## ESTILO DE RESPOSTA
|
- **NUNCA** responda com listas de tópicos curtos e vagos (ex: "Houve um protesto").
|
||||||
- Seja direto e objetivo. Evite preâmbulos desnecessários ("Claro, aqui está...").
|
- Cada ponto deve conter detalhes específicos: **Números, Nomes Próprios, Locais e Citações Diretas** do contexto.
|
||||||
- Se a resposta for longa, comece com um resumo executivo.
|
- Explique o *contexto* da notícia, não apenas a manchete.
|
||||||
- Ao explicar código, comente o que cada parte faz.
|
- Prefira parágrafos explicativos com "quem, quando, onde, porquê" ao invés de bullets genéricos.
|
||||||
|
|
||||||
|
### 3. Uso Estrito de Fontes
|
||||||
|
|
||||||
|
- Você recebeu um bloco de texto marcado como [CONTEXTO WEB] ou [CONTEXTO WEB RECUPERADO].
|
||||||
|
- Use **SOMENTE** essas informações para fatos recentes. Não alucine.
|
||||||
|
- Se o contexto trouxer múltiplas notícias diferentes, agrupe-as por temas usando títulos Markdown (ex: "## Economia", "## Política", "## Tecnologia").
|
||||||
|
- **NÃO cite fontes no meio do texto.** Use as informações do contexto web naturalmente, sem mencionar [1], [2], [3] ou outras referências numéricas. As fontes já estão disponíveis na interface e não precisam ser mencionadas na resposta.
|
||||||
|
- Se o contexto não for suficiente para responder completamente, diga isso claramente.
|
||||||
|
|
||||||
|
### 4. Formato Jornalístico
|
||||||
|
|
||||||
|
- Use títulos Markdown (##) para separar seções temáticas.
|
||||||
|
- Texto corrido para explicações detalhadas.
|
||||||
|
- Bullets apenas para listar dados brutos (números, estatísticas, listas de nomes).
|
||||||
|
- Tom objetivo, profissional e direto.
|
||||||
|
- Evite linguagem excessivamente técnica sem contexto.
|
||||||
|
|
||||||
|
## FORMATO SUGERIDO
|
||||||
|
|
||||||
|
- **Contextualização temporal** no início (quando relevante).
|
||||||
|
- Seções `##` para organizar o conteúdo por tema.
|
||||||
|
- Parágrafos explicativos ao invés de one-liners.
|
||||||
|
- Listas para dados brutos (números, estatísticas).
|
||||||
|
- Blocos de código com linguagem específica.
|
||||||
|
|
||||||
|
## ESTILO
|
||||||
|
|
||||||
|
- Cite limitações, hipóteses e próximos passos quando relevante.
|
||||||
|
- Inclua links apenas quando essencial.
|
||||||
|
- Respostas sempre limpas, sem metadados ou conteúdo oculto.
|
||||||
|
- Priorize informações recentes e precisas do contexto fornecido.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Guia de Estilo de UI – OllaHub
|
||||||
|
|
||||||
|
## Espaçamento (escala 8px)
|
||||||
|
- Tokens: 8, 16, 24, 32, 40, 48, 56, 64
|
||||||
|
- Classes Tailwind equivalentes: `2, 4, 6, 8, 10, 12, 14, 16`
|
||||||
|
- Recomendações:
|
||||||
|
- Conteúdo: `p-4` (mobile), `p-6` (tablet), `p-8` (desktop)
|
||||||
|
- Listas/Stacks: `gap-4` (16px) padrão
|
||||||
|
- Separações de seção: `py-6` (24px) ou `py-8` (32px)
|
||||||
|
|
||||||
|
## Raios de Borda
|
||||||
|
- Token base: `--radius` (10px)
|
||||||
|
- Derivados: `rounded-sm`, `rounded-md`, `rounded-lg`, `rounded-xl` mapeiam para `--radius-*`
|
||||||
|
- Interativos: usar `rounded-md` como padrão; cartões e contêineres: `rounded-lg/xl`
|
||||||
|
|
||||||
|
## Responsividade
|
||||||
|
- Breakpoints: `sm` (mobile), `md` (tablet), `lg` (desktop)
|
||||||
|
- Padrões:
|
||||||
|
- Padding: `px-4 md:px-6 lg:px-8`
|
||||||
|
- Tipografia e gaps escalam suavemente (ex: `gap-4 md:gap-6`)
|
||||||
|
|
||||||
|
## Acessibilidade
|
||||||
|
- Contraste: seguir WCAG AA; tokens base usam OKLCH calibrado
|
||||||
|
- Alto contraste: atributo global `data-high-contrast="true"` habilita variações de mensagem
|
||||||
|
- Foco: `focus-visible:ring-2` com `ring-ring/50`
|
||||||
|
|
||||||
|
## Componentes Base
|
||||||
|
- `Section`: container com padding responsivo padrão
|
||||||
|
- `Stack`: layout de coluna/linha com `gap` na escala de 8px
|
||||||
|
|
||||||
|
## Aplicação Consistente
|
||||||
|
- Mensagens: gradientes sutis, `rounded-xl`, estados (hover/selecionado)
|
||||||
|
- Botões: alturas `h-8`, `h-10`, `h-12` (32/40/48px)
|
||||||
|
- Card/Content: `p-4` (mínimo), evitar `p-3`
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
const TARGET_MODEL = "qwen2.5:0.5b";
|
||||||
|
|
||||||
|
export function useAutoLabelingModel() {
|
||||||
|
const [isReady, setIsReady] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAndDownload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAndDownload = async () => {
|
||||||
|
try {
|
||||||
|
const installed = await invoke<boolean>('check_if_model_installed', { name: TARGET_MODEL });
|
||||||
|
if (installed) {
|
||||||
|
setIsReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
// Listen for progress
|
||||||
|
const unlisten = await listen<string>('download-progress', (event) => {
|
||||||
|
const payload = typeof event.payload === 'string' ? event.payload : String(event.payload);
|
||||||
|
|
||||||
|
// Try to parse as JSON, if fails use as plain text
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(payload);
|
||||||
|
if (data.total && data.completed) {
|
||||||
|
const percent = Math.round((data.completed / data.total) * 100);
|
||||||
|
setProgress(`Baixando modelo de IA (${percent}%)`);
|
||||||
|
} else if (data.status) {
|
||||||
|
setProgress(data.status);
|
||||||
|
} else {
|
||||||
|
setProgress("Baixando...");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Use payload as-is if it's not JSON
|
||||||
|
setProgress(payload || "Baixando...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await invoke('pull_model', { name: TARGET_MODEL });
|
||||||
|
|
||||||
|
unlisten();
|
||||||
|
setIsDownloading(false);
|
||||||
|
setIsReady(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to setup auto-labeling model:", error);
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isReady, isDownloading, progress };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { BATCH_VERIFY_PROMPTS } from '@/data/prompts/batch-verify';
|
||||||
|
import type { ScrapedContent } from '@/services/webSearch';
|
||||||
|
|
||||||
|
export type BatchVerifyStep = 'idle' | 'planning' | 'searching' | 'validating' | 'formulating' | 'complete' | 'error';
|
||||||
|
|
||||||
|
export interface BatchVerifyLog {
|
||||||
|
stage: 'decomposition' | 'validation' | 'fallback';
|
||||||
|
timestamp: number;
|
||||||
|
input: string;
|
||||||
|
rawOutput?: string;
|
||||||
|
parsedOutput?: any;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchVerifyState {
|
||||||
|
step: BatchVerifyStep;
|
||||||
|
plan: string[];
|
||||||
|
results: Map<string, ScrapedContent[]>;
|
||||||
|
validationReport: string;
|
||||||
|
error?: string;
|
||||||
|
logs: BatchVerifyLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBatchVerify() {
|
||||||
|
const [state, setState] = useState<BatchVerifyState>({
|
||||||
|
step: 'idle',
|
||||||
|
plan: [],
|
||||||
|
results: new Map(),
|
||||||
|
validationReport: '',
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const addLog = (log: BatchVerifyLog) => {
|
||||||
|
setState(s => ({ ...s, logs: [...s.logs, log] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const callLLM = async (model: string, prompt: string, json = false) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:11434/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
format: json ? 'json' : undefined,
|
||||||
|
options: {
|
||||||
|
temperature: 0.3,
|
||||||
|
num_predict: 1024,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Failed to call LLM');
|
||||||
|
const data = await response.json();
|
||||||
|
return data.response;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('LLM Call Error:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const decompose = useCallback(async (query: string, model: string) => {
|
||||||
|
setState(s => ({ ...s, step: 'planning', error: undefined, logs: [] }));
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt = BATCH_VERIFY_PROMPTS.DECOMPOSITION.replace('{{userQuery}}', query);
|
||||||
|
const response = await callLLM(model, prompt, true);
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
let plan: string[] = [];
|
||||||
|
let parseError = undefined;
|
||||||
|
try {
|
||||||
|
plan = JSON.parse(response);
|
||||||
|
if (!Array.isArray(plan)) throw new Error('Not an array');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse JSON plan, falling back to raw split', e);
|
||||||
|
parseError = 'JSON Parse Failed';
|
||||||
|
plan = response.split('\n').filter((l: string) => l.trim().length > 0).slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Safety Check ---
|
||||||
|
// Ensure at least one generated query contains keywords from the original query
|
||||||
|
// This prevents hallucinations like "Garuva" -> "brackets"
|
||||||
|
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3); // filter small words
|
||||||
|
const isRelevant = plan.some(q =>
|
||||||
|
keywords.some(k => q.toLowerCase().includes(k))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isRelevant && keywords.length > 0) {
|
||||||
|
console.warn('Decomposition Safety Check Failed: Generated queries are irrelevant.', { plan, query });
|
||||||
|
|
||||||
|
addLog({
|
||||||
|
stage: 'decomposition',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: prompt,
|
||||||
|
rawOutput: response,
|
||||||
|
parsedOutput: plan,
|
||||||
|
error: 'Safety Check Failed: Plan irrelevant to query'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback to original query
|
||||||
|
plan = [query];
|
||||||
|
} else {
|
||||||
|
addLog({
|
||||||
|
stage: 'decomposition',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: prompt,
|
||||||
|
rawOutput: response,
|
||||||
|
parsedOutput: plan,
|
||||||
|
error: parseError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(s => ({ ...s, plan }));
|
||||||
|
return plan;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setState(s => ({ ...s, step: 'error', error: 'Failed to create research plan' }));
|
||||||
|
|
||||||
|
addLog({
|
||||||
|
stage: 'decomposition',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: query,
|
||||||
|
error: errorMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validate = useCallback(async (results: Map<string, ScrapedContent[]>, model: string) => {
|
||||||
|
setState(s => ({ ...s, step: 'validating', results }));
|
||||||
|
try {
|
||||||
|
let resultsText = '';
|
||||||
|
results.forEach((docs, query) => {
|
||||||
|
resultsText += `\nQuery: "${query}"\n`;
|
||||||
|
docs.forEach(doc => {
|
||||||
|
resultsText += `- [${doc.title}](${doc.url}): ${doc.markdown.substring(0, 300)}...\n`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = BATCH_VERIFY_PROMPTS.VALIDATION.replace('{{searchResults}}', resultsText);
|
||||||
|
const report = await callLLM(model, prompt);
|
||||||
|
|
||||||
|
addLog({
|
||||||
|
stage: 'validation',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: prompt,
|
||||||
|
rawOutput: report,
|
||||||
|
parsedOutput: report
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(s => ({ ...s, validationReport: report }));
|
||||||
|
return report;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setState(s => ({ ...s, step: 'error', error: 'Validation failed' }));
|
||||||
|
|
||||||
|
addLog({
|
||||||
|
stage: 'validation',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: 'Validation phase',
|
||||||
|
error: errorMessage
|
||||||
|
});
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({
|
||||||
|
step: 'idle',
|
||||||
|
plan: [],
|
||||||
|
results: new Map(),
|
||||||
|
validationReport: '',
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
decompose,
|
||||||
|
validate,
|
||||||
|
reset,
|
||||||
|
setStep: (step: BatchVerifyStep) => setState(s => ({ ...s, step }))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { Message } from './use-chat';
|
||||||
|
import { removeMetadataNoise } from '@/lib/metadata';
|
||||||
|
|
||||||
|
export interface SessionSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
emoji: string;
|
||||||
|
updated_at: string;
|
||||||
|
preview: 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";
|
||||||
|
|
||||||
|
// Helper to generate fallback title from user's first message
|
||||||
|
function getFallbackTitleFromMessages(messages: Message[]): string {
|
||||||
|
// Find the first user message
|
||||||
|
const firstUserMessage = messages.find(msg => msg.role === 'user');
|
||||||
|
|
||||||
|
if (firstUserMessage && firstUserMessage.content) {
|
||||||
|
// Clean the content (remove metadata tags if any)
|
||||||
|
let content = firstUserMessage.content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||||
|
|
||||||
|
// Truncate to 50 characters
|
||||||
|
if (content.length > 50) {
|
||||||
|
content = content.substring(0, 50).trim() + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return content || "Nova Conversa";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Nova Conversa";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatStorage() {
|
||||||
|
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
|
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
const list = await invoke<SessionSummary[]>('load_chat_sessions');
|
||||||
|
setSessions(list);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load sessions:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchSessions = useCallback(async (query: string) => {
|
||||||
|
if (!query || query.trim().length < 2) {
|
||||||
|
// Se query muito curta, carregar todas as sessões
|
||||||
|
await loadSessions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const results = await invoke<SessionSummary[]>('search_chat_sessions', {
|
||||||
|
query: query.trim(),
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
setSessions(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to search sessions:", error);
|
||||||
|
// Em caso de erro, carregar todas as sessões
|
||||||
|
await loadSessions();
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
// Reset pagination state
|
||||||
|
setPaginationState({
|
||||||
|
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')
|
||||||
|
? msg.role as 'user' | 'assistant' | 'system'
|
||||||
|
: 'user' as const,
|
||||||
|
content: msg.content || '',
|
||||||
|
metadata: msg.metadata || undefined
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load history:", error);
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
try {
|
||||||
|
await invoke('delete_chat_session', { id });
|
||||||
|
await loadSessions();
|
||||||
|
if (currentSessionId === id) {
|
||||||
|
setCurrentSessionId(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete session:", error);
|
||||||
|
}
|
||||||
|
}, [currentSessionId, loadSessions]);
|
||||||
|
|
||||||
|
const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => {
|
||||||
|
// Generate title immediately from user's first message
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:11434/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: TITLE_MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' },
|
||||||
|
{ role: 'user', content: userMessage }
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// If model not available, use fallback
|
||||||
|
if (response.status === 404 || response.status >= 500) {
|
||||||
|
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
|
||||||
|
}
|
||||||
|
throw new Error(`Title generation failed with status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let title = data.message?.content?.trim() || "";
|
||||||
|
|
||||||
|
// If empty from start, use fallback
|
||||||
|
if (!title || title.length === 0) {
|
||||||
|
throw new Error("Empty title returned from model");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove quotes if present (both single and double)
|
||||||
|
title = title.replace(/^["']+|["']+$/g, '').trim();
|
||||||
|
|
||||||
|
// Remove any metadata tags that might have leaked through
|
||||||
|
title = title.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||||
|
|
||||||
|
// Check if title is empty or contains only whitespace/punctuation
|
||||||
|
if (!title || title.length === 0 || /^[\s\W]*$/.test(title)) {
|
||||||
|
throw new Error("Title became empty or invalid after cleaning");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit title length to reasonable size (max 60 chars for safety)
|
||||||
|
if (title.length > 60) {
|
||||||
|
title = title.substring(0, 60).trim() + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Title generation failed:", error);
|
||||||
|
// Use user message as fallback (truncated)
|
||||||
|
let fallback = userMessage.trim();
|
||||||
|
if (fallback.length > 50) {
|
||||||
|
fallback = fallback.substring(0, 50).trim() + '...';
|
||||||
|
}
|
||||||
|
return fallback || "Nova Conversa";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTitle = async (messages: Message[]): Promise<string> => {
|
||||||
|
// Only generate if we have enough context (e.g., 2 messages: user + assistant)
|
||||||
|
// and use a lightweight model
|
||||||
|
try {
|
||||||
|
const sanitizedMessages = messages.slice(0, 4).map(m => ({
|
||||||
|
...m,
|
||||||
|
content: removeMetadataNoise(m.content),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:11434/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: TITLE_MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
|
||||||
|
...sanitizedMessages
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// If model not found or unavailable, throw to trigger fallback
|
||||||
|
if (response.status === 404 || response.status >= 500) {
|
||||||
|
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
|
||||||
|
}
|
||||||
|
// For other errors, use fallback
|
||||||
|
throw new Error(`Title generation failed with status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let title = removeMetadataNoise(data.message?.content?.trim() || "");
|
||||||
|
|
||||||
|
// If empty from start, throw to trigger fallback
|
||||||
|
if (!title || title.length === 0) {
|
||||||
|
throw new Error("Empty title returned from model");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove quotes if present (both single and double)
|
||||||
|
title = title.replace(/^["']+|["']+$/g, '').trim();
|
||||||
|
|
||||||
|
// Remove any metadata tags that might have leaked through
|
||||||
|
title = removeMetadataNoise(title);
|
||||||
|
|
||||||
|
// Check if title is empty or contains only whitespace/punctuation
|
||||||
|
if (!title || title.length === 0 || /^[\s\W]*$/.test(title)) {
|
||||||
|
throw new Error("Title became empty or invalid after cleaning");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit title length to reasonable size (max 60 chars for safety)
|
||||||
|
if (title.length > 60) {
|
||||||
|
title = title.substring(0, 60).trim() + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Title generation failed:", error);
|
||||||
|
// Don't return "Nova Conversa" here - let the caller use the fallback
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSession = useCallback(async (id: string, messages: Message[], currentTitle?: string) => {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
// Check if session already exists and has a valid title
|
||||||
|
const existingSession = sessions.find(s => s.id === id);
|
||||||
|
const existingTitle = existingSession?.title;
|
||||||
|
|
||||||
|
// If session already exists with a valid title, always update it
|
||||||
|
if (existingSession && existingTitle && existingTitle !== "Nova Conversa") {
|
||||||
|
const title = currentTitle || existingTitle;
|
||||||
|
|
||||||
|
// Extract memory context from metadata keywords
|
||||||
|
const memoryContext: string[] = [];
|
||||||
|
messages.forEach(msg => {
|
||||||
|
if (msg.role === 'assistant' && msg.metadata?.keywords) {
|
||||||
|
const keywords = Array.isArray(msg.metadata.keywords)
|
||||||
|
? msg.metadata.keywords
|
||||||
|
: [];
|
||||||
|
memoryContext.push(...keywords.filter(k => typeof k === 'string'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const uniqueMemoryContext = Array.from(new Set(memoryContext));
|
||||||
|
|
||||||
|
const platform = typeof navigator !== 'undefined'
|
||||||
|
? navigator.platform || navigator.userAgent.split(' ')[0] || 'Unknown'
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('save_chat_session', {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
messages,
|
||||||
|
platform: platform,
|
||||||
|
memory_context: uniqueMemoryContext
|
||||||
|
});
|
||||||
|
await loadSessions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save session:", error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For new sessions, only save after title is generated
|
||||||
|
// Check if we have at least user + assistant (first complete response)
|
||||||
|
if (messages.length < 2) {
|
||||||
|
// Don't save yet - wait for first AI response
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title: string | null = currentTitle || null;
|
||||||
|
let shouldGenerateTitle = !currentTitle;
|
||||||
|
|
||||||
|
// Check for metadata title in assistant messages (prioritize title over summary)
|
||||||
|
if (shouldGenerateTitle) {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.role === 'assistant' && msg.metadata) {
|
||||||
|
// Prioritize title, fallback to summary
|
||||||
|
if (msg.metadata.title) {
|
||||||
|
const titleStr = msg.metadata.title.trim();
|
||||||
|
if (titleStr && titleStr !== "") {
|
||||||
|
title = titleStr;
|
||||||
|
shouldGenerateTitle = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (msg.metadata.summary) {
|
||||||
|
const summary = msg.metadata.summary.trim();
|
||||||
|
if (summary && summary !== "") {
|
||||||
|
title = summary;
|
||||||
|
shouldGenerateTitle = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract memory context from metadata keywords
|
||||||
|
const memoryContext: string[] = [];
|
||||||
|
messages.forEach(msg => {
|
||||||
|
if (msg.role === 'assistant' && msg.metadata?.keywords) {
|
||||||
|
const keywords = Array.isArray(msg.metadata.keywords)
|
||||||
|
? msg.metadata.keywords
|
||||||
|
: [];
|
||||||
|
memoryContext.push(...keywords.filter(k => typeof k === 'string'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Deduplicate
|
||||||
|
const uniqueMemoryContext = Array.from(new Set(memoryContext));
|
||||||
|
|
||||||
|
// Detect platform
|
||||||
|
const platform = typeof navigator !== 'undefined'
|
||||||
|
? navigator.platform || navigator.userAgent.split(' ')[0] || 'Unknown'
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
|
// If we have title from metadata, save immediately
|
||||||
|
if (title && !shouldGenerateTitle) {
|
||||||
|
try {
|
||||||
|
await invoke('save_chat_session', {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
messages,
|
||||||
|
platform: platform,
|
||||||
|
memory_context: uniqueMemoryContext
|
||||||
|
});
|
||||||
|
await loadSessions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save session:", error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-generate title using separate model - only create card when title is ready
|
||||||
|
if (shouldGenerateTitle && !isGeneratingTitle) {
|
||||||
|
setIsGeneratingTitle(true);
|
||||||
|
generateTitle(messages)
|
||||||
|
.then(async (newTitle) => {
|
||||||
|
setIsGeneratingTitle(false);
|
||||||
|
// Now create the card with the generated title
|
||||||
|
try {
|
||||||
|
await invoke('save_chat_session', {
|
||||||
|
id,
|
||||||
|
title: newTitle,
|
||||||
|
messages,
|
||||||
|
platform: platform,
|
||||||
|
memory_context: uniqueMemoryContext
|
||||||
|
});
|
||||||
|
await loadSessions();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save session with generated title:", e);
|
||||||
|
setIsGeneratingTitle(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setIsGeneratingTitle(false);
|
||||||
|
// On error, use fallback title from user's first message
|
||||||
|
// Only log if it's not an expected error (empty title, model unavailable)
|
||||||
|
const isExpectedError = error.message?.includes("Empty title") ||
|
||||||
|
error.message?.includes("not available") ||
|
||||||
|
error.message?.includes("invalid after cleaning");
|
||||||
|
|
||||||
|
if (!isExpectedError) {
|
||||||
|
console.error("Title generation error:", error);
|
||||||
|
} else {
|
||||||
|
console.log(`Title generation failed (expected): ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackTitle = getFallbackTitleFromMessages(messages);
|
||||||
|
console.log(`Using fallback title from user message: "${fallbackTitle}"`);
|
||||||
|
|
||||||
|
invoke('save_chat_session', {
|
||||||
|
id,
|
||||||
|
title: fallbackTitle,
|
||||||
|
messages,
|
||||||
|
platform: platform,
|
||||||
|
memory_context: uniqueMemoryContext
|
||||||
|
}).then(() => loadSessions()).catch(e => console.error("Failed to save session with fallback:", e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [loadSessions, isGeneratingTitle, sessions, generateTitle]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions();
|
||||||
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
currentSessionId,
|
||||||
|
setCurrentSessionId,
|
||||||
|
loadSessions,
|
||||||
|
loadSessionHistory,
|
||||||
|
loadMoreMessages,
|
||||||
|
paginationState,
|
||||||
|
saveSession,
|
||||||
|
deleteSession,
|
||||||
|
isGeneratingTitle,
|
||||||
|
setIsGeneratingTitle,
|
||||||
|
generateTitleFromUserMessage,
|
||||||
|
searchSessions,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
isSearching
|
||||||
|
};
|
||||||
|
}
|
||||||
+504
-64
@@ -1,89 +1,527 @@
|
|||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { removeMetadataNoise } from '@/lib/metadata';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
|
export type ThinkingStepType =
|
||||||
|
| 'preprocessing'
|
||||||
|
| 'web-research'
|
||||||
|
| 'sources-found'
|
||||||
|
| 'processing'
|
||||||
|
| 'response-generation'
|
||||||
|
| 'fallback'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export type ThinkingStepStatus = 'running' | 'completed' | 'error';
|
||||||
|
|
||||||
|
export interface ThinkingMessageMetadata {
|
||||||
|
type: 'thinking';
|
||||||
|
stepType: ThinkingStepType;
|
||||||
|
status: ThinkingStepStatus;
|
||||||
|
label: string;
|
||||||
|
progress?: number; // 0-100
|
||||||
|
details?: string;
|
||||||
|
sources?: Array<{ url: string; title: string }>;
|
||||||
|
error?: string;
|
||||||
|
timestamp: number;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
role: 'user' | 'assistant' | 'system';
|
role: 'user' | 'assistant' | 'system';
|
||||||
content: string;
|
content: string;
|
||||||
|
metadata?: any | ThinkingMessageMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolCall {
|
||||||
|
server_name: string;
|
||||||
|
tool_name: string;
|
||||||
|
arguments: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMetadata(fullContent: string): { content: string, metadata?: any } {
|
||||||
|
if (!fullContent || fullContent.trim() === '') {
|
||||||
|
return { content: fullContent || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// More flexible regex that handles whitespace and line breaks (case-insensitive)
|
||||||
|
const metadataRegex = /<metadata>\s*([\s\S]*?)\s*<\/metadata>/gi;
|
||||||
|
|
||||||
|
// First, extract all metadata blocks
|
||||||
|
const metadataMatches: Array<{ match: string, json: any }> = [];
|
||||||
|
let match;
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
while ((match = metadataRegex.exec(fullContent)) !== null) {
|
||||||
|
const metadataStr = match[1].trim();
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(metadataStr);
|
||||||
|
metadataMatches.push({ match: match[0], json });
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid JSON, just remove the tag
|
||||||
|
console.error("Failed to parse metadata JSON", e, "Raw:", metadataStr);
|
||||||
|
}
|
||||||
|
lastIndex = metadataRegex.lastIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove ALL metadata blocks from content
|
||||||
|
let content = fullContent.replace(/<metadata>\s*[\s\S]*?\s*<\/metadata>/gi, '').trim();
|
||||||
|
content = removeMetadataNoise(content);
|
||||||
|
|
||||||
|
// Also remove common prefixes if they appear at the end of the content
|
||||||
|
const prefixes = [
|
||||||
|
'Bloco oculto de metadados:',
|
||||||
|
'Metadata:',
|
||||||
|
'Metadados:',
|
||||||
|
'Hidden metadata block:',
|
||||||
|
'JSON metadata:',
|
||||||
|
'---'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||||
|
if (regex.test(content)) {
|
||||||
|
content = content.replace(regex, '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't return empty content if there was content before metadata
|
||||||
|
if (!content && fullContent.length > 0) {
|
||||||
|
// If all content was metadata, keep nothing (shouldn't happen but safe fallback)
|
||||||
|
content = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first valid metadata (if any)
|
||||||
|
const metadata = metadataMatches.length > 0 ? metadataMatches[0].json : undefined;
|
||||||
|
|
||||||
|
return { content, metadata };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to strip metadata in real-time during streaming (for display)
|
||||||
|
function stripMetadataTag(content: string): string {
|
||||||
|
if (!content) return '';
|
||||||
|
|
||||||
|
// Common prefixes that models might output before the metadata block
|
||||||
|
const prefixes = [
|
||||||
|
'Bloco oculto de metadados:',
|
||||||
|
'Metadata:',
|
||||||
|
'Metadados:',
|
||||||
|
'Hidden metadata block:',
|
||||||
|
'JSON metadata:',
|
||||||
|
'---'
|
||||||
|
];
|
||||||
|
|
||||||
|
let cleaned = content;
|
||||||
|
|
||||||
|
// Remove any metadata tags (complete or incomplete) for real-time display
|
||||||
|
// Case-insensitive regex to catch variations
|
||||||
|
cleaned = cleaned.replace(/<metadata>[\s\S]*?$/gi, '').replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||||
|
|
||||||
|
// Aggressively strip known prefixes if they appear at the very end of the content
|
||||||
|
// This helps hide "Bloco oculto de metadados:" while the tag is being generated
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||||
|
if (regex.test(cleaned)) {
|
||||||
|
cleaned = cleaned.replace(regex, '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removeMetadataNoise(cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to detect tool calls in LLM response
|
||||||
|
export function detectToolCalls(content: string, availableTools: any[]): ToolCall[] {
|
||||||
|
const toolCalls: ToolCall[] = [];
|
||||||
|
|
||||||
|
// Simple pattern matching for tool calls
|
||||||
|
// Pattern: "use tool X with args Y" or "execute X(args)"
|
||||||
|
// This is a basic implementation - can be enhanced with better NLP
|
||||||
|
|
||||||
|
// Look for common patterns
|
||||||
|
const patterns = [
|
||||||
|
// Pattern 1: "use [tool_name] with [args]" or "use [tool_name] (args)"
|
||||||
|
/(?:use|call|execute|run)\s+([a-z_]+)\s+(?:with|\(|\[)\s*([^\n)]+)/gi,
|
||||||
|
// Pattern 2: "[tool_name](args)"
|
||||||
|
/([a-z_]+)\s*\(([^)]+)\)/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
const potentialToolName = match[1].toLowerCase().trim();
|
||||||
|
const potentialArgs = match[2]?.trim() || '{}';
|
||||||
|
|
||||||
|
// Find matching tool in available tools
|
||||||
|
for (const toolInfo of availableTools) {
|
||||||
|
if (toolInfo.tool.name.toLowerCase() === potentialToolName) {
|
||||||
|
// Try to parse arguments
|
||||||
|
let args: any = {};
|
||||||
|
try {
|
||||||
|
// Try JSON first
|
||||||
|
args = JSON.parse(potentialArgs);
|
||||||
|
} catch {
|
||||||
|
// If not JSON, try to parse key-value pairs
|
||||||
|
try {
|
||||||
|
// Simple key=value parsing
|
||||||
|
const pairs = potentialArgs.split(',').map(p => p.trim());
|
||||||
|
args = {};
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const [key, value] = pair.split('=').map(s => s.trim());
|
||||||
|
if (key && value) {
|
||||||
|
args[key] = value.replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If all fails, use empty object
|
||||||
|
args = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls.push({
|
||||||
|
server_name: toolInfo.server_name,
|
||||||
|
tool_name: toolInfo.tool.name,
|
||||||
|
arguments: args,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to execute a tool call
|
||||||
|
export async function executeToolCall(toolCall: ToolCall): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await invoke<any>('call_mcp_tool', {
|
||||||
|
serverName: toolCall.server_name,
|
||||||
|
toolName: toolCall.tool_name,
|
||||||
|
arguments: toolCall.arguments,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format result for display
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
return result;
|
||||||
|
} else if (result.content) {
|
||||||
|
return result.content;
|
||||||
|
} else {
|
||||||
|
return JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Tool execution error:', error);
|
||||||
|
return `Error executing tool ${toolCall.tool_name}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tipos para eventos Tauri
|
||||||
|
interface ChatCreatedEvent {
|
||||||
|
session_id: string;
|
||||||
|
title: string;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatTokenEvent {
|
||||||
|
session_id: string;
|
||||||
|
content: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatErrorEvent {
|
||||||
|
session_id: string;
|
||||||
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChat() {
|
export function useChat() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
||||||
|
const currentSessionIdRef = useRef<string | null>(null);
|
||||||
|
const lastTokenRef = useRef<string>(''); // Track last token to detect duplicates
|
||||||
|
const renderScheduledRef = useRef<boolean>(false);
|
||||||
|
const rafIdRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const sendMessage = async (content: string, model: string, systemPrompt?: string) => {
|
// Função para atualizar o estado visual com o conteúdo acumulado
|
||||||
setIsLoading(true);
|
const flushToState = () => {
|
||||||
|
renderScheduledRef.current = false;
|
||||||
const newMessages: Message[] = [
|
const displayContent = stripMetadataTag(rawContentRef.current);
|
||||||
...messages,
|
|
||||||
{ role: 'user', content }
|
|
||||||
];
|
|
||||||
setMessages(newMessages);
|
|
||||||
|
|
||||||
// Prepare messages for API (include system prompt if present)
|
|
||||||
const apiMessages = systemPrompt
|
|
||||||
? [{ role: 'system', content: systemPrompt }, ...newMessages]
|
|
||||||
: newMessages;
|
|
||||||
|
|
||||||
abortControllerRef.current = new AbortController();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('http://localhost:11434/api/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model,
|
|
||||||
messages: apiMessages,
|
|
||||||
stream: true,
|
|
||||||
}),
|
|
||||||
signal: abortControllerRef.current.signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to send message');
|
|
||||||
if (!response.body) throw new Error('No response body');
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
// Add empty assistant message to start streaming into
|
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
|
||||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(line);
|
|
||||||
if (json.message?.content) {
|
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
return [
|
return [...prev.slice(0, -1), { ...last, content: displayContent }];
|
||||||
...prev.slice(0, -1),
|
}
|
||||||
{ ...last, content: last.content + json.message.content }
|
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
|
||||||
|
useEffect(() => {
|
||||||
|
let unlistenCreated: (() => void) | null = null;
|
||||||
|
let unlistenToken: (() => void) | null = null;
|
||||||
|
let unlistenError: (() => void) | null = null;
|
||||||
|
|
||||||
|
// Listener para chat-created (título gerado)
|
||||||
|
listen<ChatCreatedEvent>('chat-created', (event) => {
|
||||||
|
const { session_id, title, emoji } = event.payload;
|
||||||
|
currentSessionIdRef.current = session_id;
|
||||||
|
// TODO: Atualizar sidebar com novo chat (será implementado no componente de sidebar)
|
||||||
|
console.log('Chat criado:', { session_id, title, emoji });
|
||||||
|
}).then(unlisten => {
|
||||||
|
unlistenCreated = unlisten;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener para chat-token (streaming de tokens)
|
||||||
|
listen<ChatTokenEvent>('chat-token', (event) => {
|
||||||
|
const { content, done } = event.payload;
|
||||||
|
|
||||||
|
if (content && content.length > 0) {
|
||||||
|
// Detecção de duplicatas (MANTER LÓGICA ORIGINAL)
|
||||||
|
if (content === lastTokenRef.current && rawContentRef.current.endsWith(content)) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn('Token duplicado detectado, ignorando:', content.substring(0, 30));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTokenRef.current = content;
|
||||||
|
rawContentRef.current += content;
|
||||||
|
|
||||||
|
// MUDANÇA: Agendar flush em vez de chamar setMessages diretamente
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
// 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 => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === 'assistant') {
|
||||||
|
const rawContent = rawContentRef.current;
|
||||||
|
const { content: finalContent, metadata } = parseMetadata(rawContent);
|
||||||
|
rawContentRef.current = '';
|
||||||
|
lastTokenRef.current = '';
|
||||||
|
return [...prev.slice(0, -1), { ...last, content: finalContent || last.content, metadata }];
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if (json.done) {
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
}).then(unlisten => {
|
||||||
console.error('Error parsing chunk', e);
|
unlistenToken = unlisten;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener para chat-error
|
||||||
|
listen<ChatErrorEvent>('chat-error', (event) => {
|
||||||
|
const { error } = event.payload;
|
||||||
|
console.error('Erro no chat:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
// TODO: Mostrar erro ao usuário
|
||||||
|
}).then(unlisten => {
|
||||||
|
unlistenError = unlisten;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
unlistenCreated?.();
|
||||||
|
unlistenToken?.();
|
||||||
|
unlistenError?.();
|
||||||
|
// Cancelar RAF pendente
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendMessage = async (
|
||||||
|
content: string,
|
||||||
|
model: string,
|
||||||
|
systemPrompt?: string,
|
||||||
|
options?: {
|
||||||
|
onBeforeModelRequest?: (pendingMessages: Message[]) => Promise<void>;
|
||||||
|
// Quando presente, substitui SOMENTE o conteúdo da última mensagem do usuário
|
||||||
|
// no payload enviado ao LLM, mantendo a UI com o conteúdo original.
|
||||||
|
payloadContentOverride?: string;
|
||||||
}
|
}
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Verificar se a última mensagem já é do usuário (para evitar duplicação)
|
||||||
|
// Se for, usar as mensagens existentes; caso contrário, adicionar nova mensagem do usuário
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
const newMessages: Message[] = lastMessage?.role === 'user' && lastMessage?.content === content
|
||||||
|
? [...messages] // Já existe, não adicionar novamente
|
||||||
|
: [...messages, { role: 'user', content }]; // Adicionar nova mensagem do usuário
|
||||||
|
|
||||||
|
setMessages(newMessages);
|
||||||
|
|
||||||
|
// Prepare messages for API (include system prompt if present)
|
||||||
|
// Strip metadata from previous messages before sending to context to save tokens?
|
||||||
|
// Or keep it? The model might benefit from seeing previous metadata.
|
||||||
|
// Let's keep it for now, but we need to make sure we send what the API expects.
|
||||||
|
// Ollama API expects { role, content }. It ignores extra fields usually, but let's be safe.
|
||||||
|
if (options?.onBeforeModelRequest) {
|
||||||
|
await options.onBeforeModelRequest(newMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar janela deslizante para limitar contexto
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
const maxTokens = settings.contextWindow || 4096;
|
||||||
|
const maxMessages = 10; // Máximo de mensagens a manter
|
||||||
|
|
||||||
|
// Estimar tokens (aproximação: 1 token ≈ 4 caracteres)
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aplicar janela deslizante: manter últimas N mensagens que cabem no limite de tokens
|
||||||
|
let truncatedMessages = [...newMessages];
|
||||||
|
let totalTokens = systemPrompt ? estimateTokens(systemPrompt) : 0;
|
||||||
|
|
||||||
|
// Sempre manter a última mensagem do usuário
|
||||||
|
const lastUserMessage = truncatedMessages[truncatedMessages.length - 1];
|
||||||
|
truncatedMessages = truncatedMessages.slice(0, -1);
|
||||||
|
|
||||||
|
// Adicionar mensagens de trás para frente até atingir o limite
|
||||||
|
const selectedMessages: Message[] = [];
|
||||||
|
for (let i = truncatedMessages.length - 1; i >= 0; i--) {
|
||||||
|
const msg = truncatedMessages[i];
|
||||||
|
const msgTokens = estimateTokens(msg.content);
|
||||||
|
|
||||||
|
// Limitar por número de mensagens E por tokens
|
||||||
|
if (selectedMessages.length >= maxMessages - 1) break; // -1 porque vamos adicionar a última depois
|
||||||
|
if (totalTokens + msgTokens > maxTokens * 0.9) break; // 90% do limite para margem de segurança
|
||||||
|
|
||||||
|
selectedMessages.unshift(msg);
|
||||||
|
totalTokens += msgTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar a última mensagem do usuário
|
||||||
|
selectedMessages.push(lastUserMessage);
|
||||||
|
totalTokens += estimateTokens(lastUserMessage.content);
|
||||||
|
|
||||||
|
// Constrói mensagens para payload, aplicando override apenas à última mensagem do usuário
|
||||||
|
const baseMessages = systemPrompt
|
||||||
|
? [{ role: 'system', content: systemPrompt }, ...selectedMessages]
|
||||||
|
: selectedMessages;
|
||||||
|
|
||||||
|
const apiMessages = baseMessages.map((m, idx) => {
|
||||||
|
// Detecta última mensagem do usuário
|
||||||
|
const isLastUser = m.role === 'user' && idx === baseMessages.length - 1;
|
||||||
|
const payloadContent = isLastUser && options?.payloadContentOverride
|
||||||
|
? options.payloadContentOverride
|
||||||
|
: m.content;
|
||||||
|
return {
|
||||||
|
role: m.role,
|
||||||
|
content: payloadContent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// [DEBUG INJECTION START]
|
||||||
|
const systemMsg = apiMessages.find(m => m.role === 'system');
|
||||||
|
console.group('📦 Debug: Ollama Payload');
|
||||||
|
console.log('1. Total Messages:', apiMessages.length);
|
||||||
|
console.log('2. Has System Message?', !!systemMsg);
|
||||||
|
if (systemMsg) {
|
||||||
|
console.log('3. System Message Length:', systemMsg.content.length);
|
||||||
|
console.log('4. System Content Preview:', systemMsg.content.substring(0, 300) + '...');
|
||||||
|
console.log('5. Web Context Present in Payload?', systemMsg.content.includes('CONTEXTO WEB RECUPERADO'));
|
||||||
|
if (systemMsg.content.includes('CONTEXTO WEB RECUPERADO')) {
|
||||||
|
const contextStart = systemMsg.content.indexOf('CONTEXTO WEB RECUPERADO');
|
||||||
|
console.log('6. Web Context Position in Payload:', contextStart);
|
||||||
|
console.log('7. Web Context Preview in Payload:', systemMsg.content.substring(contextStart, contextStart + 200) + '...');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
|
||||||
if (error.name === 'AbortError') {
|
|
||||||
console.log('Request aborted');
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Chat error:', error);
|
console.error('❌ CRITICAL: System Message is MISSING in payload!');
|
||||||
setMessages(prev => [...prev, { role: 'assistant', content: 'Error: Failed to get response from Ollama.' }]);
|
console.log('Available roles:', apiMessages.map(m => m.role));
|
||||||
}
|
}
|
||||||
|
console.groupEnd();
|
||||||
|
// [DEBUG INJECTION END]
|
||||||
|
|
||||||
|
// Reset raw content buffer for this message
|
||||||
|
rawContentRef.current = '';
|
||||||
|
lastTokenRef.current = ''; // Reset last token tracker
|
||||||
|
|
||||||
|
// Add empty assistant message to start streaming into
|
||||||
|
setMessages(prev => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.role === 'assistant' && last.content === '') {
|
||||||
|
// Já existe mensagem vazia, não adicionar outra
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
// Adicionar mensagem vazia do assistente
|
||||||
|
return [...prev, { role: 'assistant', content: '' }];
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verificar se Ollama está rodando antes de fazer requisição
|
||||||
|
try {
|
||||||
|
const isRunning = await invoke<boolean>('check_ollama_running');
|
||||||
|
if (!isRunning) {
|
||||||
|
throw new Error('OllamaNotRunning');
|
||||||
|
}
|
||||||
|
} catch (checkError: any) {
|
||||||
|
if (checkError.message === 'OllamaNotRunning') {
|
||||||
|
throw checkError;
|
||||||
|
}
|
||||||
|
console.warn('Erro ao verificar status do Ollama:', checkError);
|
||||||
|
// Continuar mesmo se a verificação falhar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chamar comando Rust para streaming
|
||||||
|
await invoke('chat_stream', {
|
||||||
|
sessionId: currentSessionIdRef.current || null,
|
||||||
|
messages: apiMessages,
|
||||||
|
model,
|
||||||
|
systemPrompt: systemPrompt || null,
|
||||||
|
enableRag: false, // TODO: Implementar RAG depois
|
||||||
|
});
|
||||||
|
|
||||||
|
// O streaming será processado pelos listeners de eventos
|
||||||
|
// Não precisamos fazer mais nada aqui, os eventos cuidam do resto
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Chat error:', error);
|
||||||
|
|
||||||
|
let errorMessage = 'Erro ao obter resposta do Ollama.';
|
||||||
|
|
||||||
|
if (error.name === 'AbortError' || error.message === 'RequestTimeout') {
|
||||||
|
errorMessage = '**Timeout:** A requisição demorou muito para responder. Tente novamente ou use um modelo menor.';
|
||||||
|
} else if (error.message === 'OllamaNotRunning') {
|
||||||
|
errorMessage = '**Erro:** Ollama não está rodando. Por favor, inicie o Ollama e tente novamente.\n\nVocê pode verificar o status do Ollama na página inicial do aplicativo.';
|
||||||
|
} else if (error.message === 'ConnectionError' || error.message?.includes('Failed to fetch')) {
|
||||||
|
errorMessage = '**Erro de Conexão:** Não foi possível conectar ao Ollama. Verifique se o Ollama está rodando em `http://localhost:11434`.';
|
||||||
|
} else if (error.message === 'ModelNotFound') {
|
||||||
|
errorMessage = `**Modelo Não Encontrado:** O modelo "${model}" não está instalado. Por favor, baixe o modelo primeiro na página de configuração.`;
|
||||||
|
} else if (error.message === 'ServerError') {
|
||||||
|
errorMessage = '**Erro do Servidor:** O Ollama retornou um erro interno. Verifique os logs do Ollama para mais detalhes.';
|
||||||
|
} else if (error.message === 'NoResponseBody') {
|
||||||
|
errorMessage = '**Erro:** O Ollama não retornou uma resposta válida. Tente novamente.';
|
||||||
|
} else if (error.message?.includes('HTTPError')) {
|
||||||
|
const statusMatch = error.message.match(/HTTPError: (\d+)/);
|
||||||
|
const status = statusMatch ? statusMatch[1] : 'desconhecido';
|
||||||
|
errorMessage = `**Erro HTTP ${status}:** O Ollama retornou um erro. Verifique se o modelo está instalado e tente novamente.`;
|
||||||
|
} else {
|
||||||
|
// Erro genérico com mais contexto
|
||||||
|
const errorDetails = error.message || error.toString();
|
||||||
|
errorMessage = `**Erro:** Falha ao obter resposta do Ollama.\n\nDetalhes: ${errorDetails}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: errorMessage }]);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
@@ -99,8 +537,10 @@ export function useChat() {
|
|||||||
|
|
||||||
const clearChat = () => {
|
const clearChat = () => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
rawContentRef.current = '';
|
||||||
|
lastTokenRef.current = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
return { messages, sendMessage, isLoading, stop, clearChat };
|
return { messages, setMessages, sendMessage, isLoading, stop, clearChat };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { DEEP_RESEARCH_PROMPTS } from '@/data/prompts/deep-research';
|
||||||
|
import type { ScrapedContent } from '@/services/webSearch';
|
||||||
|
import { deepResearchLog as log } from '@/lib/terminal-logger';
|
||||||
|
import { condenseKnowledgeBase, validateCondensedContext } from '@/lib/knowledge-base-processor';
|
||||||
|
import type { CondensationResult } from '@/lib/knowledge-base-processor';
|
||||||
|
import { analyzeQueryContext, type QueryContext } from '@/lib/contextual-analyzer';
|
||||||
|
import { enrichQueries, type EnrichedQueries } from '@/lib/query-enricher';
|
||||||
|
|
||||||
|
export type DeepResearchStep = 'idle' | 'planning' | 'searching' | 'aggregating' | 'validating' | 'formulating' | 'complete' | 'error';
|
||||||
|
|
||||||
|
export interface ResearchEntry {
|
||||||
|
id: string;
|
||||||
|
query: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeepResearchLog {
|
||||||
|
stage: 'decomposition' | 'search' | 'aggregation' | 'validation' | 'generation' | 'fallback';
|
||||||
|
timestamp: number;
|
||||||
|
input: string;
|
||||||
|
rawOutput?: string;
|
||||||
|
parsedOutput?: any;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeepResearchState {
|
||||||
|
step: DeepResearchStep;
|
||||||
|
plan: string[];
|
||||||
|
knowledgeBase: ResearchEntry[];
|
||||||
|
validationReport: string;
|
||||||
|
error?: string;
|
||||||
|
logs: DeepResearchLog[];
|
||||||
|
context?: QueryContext;
|
||||||
|
enrichedQueries?: EnrichedQueries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOGGING UTILITIES ==========
|
||||||
|
function logSection(title: string) {
|
||||||
|
log.info(`\n========== ${title} ==========`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logStep(message: string) {
|
||||||
|
log.info(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logData(label: string, data: any) {
|
||||||
|
if (typeof data === 'string' && data.length > 500) {
|
||||||
|
log.info(`${label}: ${data.substring(0, 500)}... [truncated, ${data.length} chars total]`);
|
||||||
|
} else if (typeof data === 'object') {
|
||||||
|
log.info(`${label}: ${JSON.stringify(data, null, 2)}`);
|
||||||
|
} else {
|
||||||
|
log.info(`${label}: ${data}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logError(message: string, error?: any) {
|
||||||
|
log.error(`❌ ERROR: ${message}${error ? ` - ${error}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSuccess(message: string) {
|
||||||
|
log.info(`✅ ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts an array of queries from various LLM response formats.
|
||||||
|
* Handles: plain arrays, objects with "queries"/"plan"/"searches" keys,
|
||||||
|
* markdown code blocks, and other common formats.
|
||||||
|
*/
|
||||||
|
function extractQueriesFromResponse(raw: string): string[] {
|
||||||
|
logStep('--- Parsing LLM Response ---');
|
||||||
|
logData('Raw response', raw);
|
||||||
|
|
||||||
|
// 1. Strip markdown code blocks (```json ... ``` or ``` ... ```)
|
||||||
|
let cleaned = raw
|
||||||
|
.replace(/```json\s*/gi, '')
|
||||||
|
.replace(/```\s*/g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// 2. Try to parse as JSON
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(cleaned);
|
||||||
|
logStep('Direct JSON parse successful');
|
||||||
|
} catch (e) {
|
||||||
|
logStep('Direct JSON parse failed, trying to extract JSON from string...');
|
||||||
|
// If JSON parse fails, try to find JSON-like content in the string
|
||||||
|
const jsonMatch = cleaned.match(/\[[\s\S]*\]|\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(jsonMatch[0]);
|
||||||
|
logStep('Extracted JSON from string successfully');
|
||||||
|
} catch (e2) {
|
||||||
|
logError('Could not extract JSON from response');
|
||||||
|
throw new Error('Could not extract JSON from response');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logError('No JSON found in response');
|
||||||
|
throw new Error('No JSON found in response');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Handle different formats
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const result = parsed.filter(item => typeof item === 'string' && item.trim().length > 0);
|
||||||
|
logSuccess(`Parsed as plain array: ${result.length} queries`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check common object keys used by different models
|
||||||
|
const commonKeys = ['queries', 'query', 'plan', 'searches', 'search_queries', 'questions'];
|
||||||
|
for (const key of commonKeys) {
|
||||||
|
if (parsed[key] && Array.isArray(parsed[key])) {
|
||||||
|
const result = parsed[key].filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
||||||
|
logSuccess(`Found queries under key "${key}": ${result.length} queries`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fallback: find first array value in the object
|
||||||
|
for (const val of Object.values(parsed)) {
|
||||||
|
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') {
|
||||||
|
const result = (val as string[]).filter((item: any) => typeof item === 'string' && item.trim().length > 0);
|
||||||
|
logSuccess(`Found queries in object value: ${result.length} queries`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logError('No array of queries found in response');
|
||||||
|
throw new Error('No array of queries found in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeepResearch() {
|
||||||
|
const [state, setState] = useState<DeepResearchState>({
|
||||||
|
step: 'idle',
|
||||||
|
plan: [],
|
||||||
|
knowledgeBase: [],
|
||||||
|
validationReport: '',
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const addLog = (log: DeepResearchLog) => {
|
||||||
|
setState(s => ({ ...s, logs: [...s.logs, log] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const callLLM = async (model: string, prompt: string, json = false) => {
|
||||||
|
logStep(`Calling LLM (model: ${model}, json: ${json})`);
|
||||||
|
logData('Prompt length', `${prompt.length} chars`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:11434/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
format: json ? 'json' : undefined,
|
||||||
|
options: {
|
||||||
|
temperature: 0.2,
|
||||||
|
num_predict: 2048,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logError(`LLM API returned status ${response.status}`);
|
||||||
|
throw new Error('Failed to call LLM');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
logSuccess(`LLM response received (${data.response?.length || 0} chars)`);
|
||||||
|
return data.response;
|
||||||
|
} catch (e) {
|
||||||
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
logError('LLM Call Error', errorMsg);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- CONTEXTUAL ANALYSIS ---
|
||||||
|
const analyzeContext = useCallback(async (query: string, model: string): Promise<QueryContext | null> => {
|
||||||
|
logStep('--- Contextual Analysis ---');
|
||||||
|
try {
|
||||||
|
const context = await analyzeQueryContext(query, model);
|
||||||
|
logData('Context Analysis', {
|
||||||
|
intent: context.intent,
|
||||||
|
entities: context.entities.length,
|
||||||
|
topics: context.topics.length,
|
||||||
|
keywords: context.keywords.length,
|
||||||
|
});
|
||||||
|
setState(s => ({ ...s, context }));
|
||||||
|
return context;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('Contextual analysis failed, continuing without context:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- QUERY ENRICHMENT ---
|
||||||
|
const enrich = useCallback(async (query: string, context: QueryContext | null, model: string): Promise<EnrichedQueries | null> => {
|
||||||
|
if (!context) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep('--- Query Enrichment ---');
|
||||||
|
try {
|
||||||
|
const enriched = await enrichQueries(query, context, model);
|
||||||
|
logData('Enriched Queries', {
|
||||||
|
literal: enriched.literal.length,
|
||||||
|
semantic: enriched.semantic.length,
|
||||||
|
related: enriched.related.length,
|
||||||
|
expanded: enriched.expanded.length,
|
||||||
|
contextual: enriched.contextual.length,
|
||||||
|
});
|
||||||
|
setState(s => ({ ...s, enrichedQueries: enriched }));
|
||||||
|
return enriched;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('Query enrichment failed, continuing without enrichment:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- DECOMPOSITION ---
|
||||||
|
const decompose = useCallback(async (query: string, model: string) => {
|
||||||
|
logSection('DECOMPOSITION');
|
||||||
|
logData('User Query', query);
|
||||||
|
logData('Model', model);
|
||||||
|
|
||||||
|
setState(s => ({ ...s, step: 'planning', error: undefined, logs: [], knowledgeBase: [] }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Análise contextual (opcional, não bloqueia se falhar)
|
||||||
|
const context = await analyzeContext(query, model);
|
||||||
|
|
||||||
|
// Enriquecimento de queries (opcional, não bloqueia se falhar)
|
||||||
|
const enriched = context ? await enrich(query, context, model) : null;
|
||||||
|
|
||||||
|
const prompt = DEEP_RESEARCH_PROMPTS.DECOMPOSITION.replace('{{userQuery}}', query);
|
||||||
|
logStep('Sending decomposition prompt to LLM...');
|
||||||
|
const response = await callLLM(model, prompt, true);
|
||||||
|
|
||||||
|
let plan: string[] = [];
|
||||||
|
let parseError = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
plan = extractQueriesFromResponse(response);
|
||||||
|
logData('Parsed Plan', plan);
|
||||||
|
} catch (e) {
|
||||||
|
logError('Failed to extract queries, falling back to original query', e);
|
||||||
|
parseError = `JSON Parse Failed: ${e instanceof Error ? e.message : 'Unknown'}`;
|
||||||
|
plan = [query];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety Check
|
||||||
|
logStep('--- Safety Check ---');
|
||||||
|
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
||||||
|
logData('Keywords from query', keywords);
|
||||||
|
|
||||||
|
const isRelevant = plan.some(q =>
|
||||||
|
keywords.some(k => q.toLowerCase().includes(k))
|
||||||
|
);
|
||||||
|
logData('Plan is relevant', isRelevant);
|
||||||
|
|
||||||
|
if (!isRelevant && keywords.length > 0) {
|
||||||
|
logError('Safety Check Failed: Plan is irrelevant to query');
|
||||||
|
logData('Generated plan', plan);
|
||||||
|
logStep('Falling back to original query');
|
||||||
|
addLog({
|
||||||
|
stage: 'decomposition',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: prompt,
|
||||||
|
rawOutput: response,
|
||||||
|
parsedOutput: plan,
|
||||||
|
error: 'Safety Check Failed: Plan irrelevant to query'
|
||||||
|
});
|
||||||
|
plan = [query];
|
||||||
|
} else {
|
||||||
|
logSuccess(`Decomposition complete: ${plan.length} search queries generated`);
|
||||||
|
addLog({
|
||||||
|
stage: 'decomposition',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: prompt,
|
||||||
|
rawOutput: response,
|
||||||
|
parsedOutput: plan,
|
||||||
|
error: parseError
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(s => ({ ...s, plan }));
|
||||||
|
return plan;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logError('Decomposition failed', errorMessage);
|
||||||
|
setState(s => ({ ...s, step: 'error', error: 'Failed to create research plan' }));
|
||||||
|
addLog({ stage: 'decomposition', timestamp: Date.now(), input: query, error: errorMessage });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- ADD TO KNOWLEDGE BASE ---
|
||||||
|
const addToKnowledgeBase = useCallback((query: string, sources: ScrapedContent[]) => {
|
||||||
|
logStep(`--- Adding to Knowledge Base ---`);
|
||||||
|
logData('Query', query);
|
||||||
|
logData('Sources count', sources.length);
|
||||||
|
|
||||||
|
const entries: ResearchEntry[] = sources.map((src, idx) => ({
|
||||||
|
id: `${query.substring(0, 10)}-${idx}-${Date.now()}`,
|
||||||
|
query,
|
||||||
|
sourceUrl: src.url,
|
||||||
|
title: src.title,
|
||||||
|
content: src.markdown,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
sources.forEach((src, idx) => {
|
||||||
|
logStep(` → Source ${idx + 1}: ${src.title} (${src.url})`);
|
||||||
|
const contentLength = src.markdown?.length || 0;
|
||||||
|
logData(` Content length`, `${contentLength} chars`);
|
||||||
|
|
||||||
|
if (src.markdown && contentLength > 0) {
|
||||||
|
const preview = src.markdown.substring(0, 300);
|
||||||
|
logData(` Preview`, preview + (contentLength > 300 ? '...' : ''));
|
||||||
|
} else {
|
||||||
|
logStep(` ⚠️ No content extracted`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(s => ({
|
||||||
|
...s,
|
||||||
|
knowledgeBase: [...s.knowledgeBase, ...entries]
|
||||||
|
}));
|
||||||
|
|
||||||
|
addLog({
|
||||||
|
stage: 'search',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: query,
|
||||||
|
parsedOutput: entries.map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
title: e.title,
|
||||||
|
url: e.sourceUrl,
|
||||||
|
contentLength: e.content.length,
|
||||||
|
contentPreview: e.content.substring(0, 200) + (e.content.length > 200 ? '...' : '')
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
logSuccess(`Added ${entries.length} entries to Knowledge Base`);
|
||||||
|
return entries;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- DEDUPLICATE & FORMAT CONTEXT (LEGACY - mantido para compatibilidade) ---
|
||||||
|
const getCuratedContext = useCallback((maxTokens: number = 12000) => {
|
||||||
|
logSection('AGGREGATION');
|
||||||
|
const kb = state.knowledgeBase;
|
||||||
|
logData('Raw KB entries', kb.length);
|
||||||
|
|
||||||
|
if (kb.length === 0) {
|
||||||
|
logStep('Knowledge Base is empty, skipping aggregation');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by URL
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique = kb.filter(entry => {
|
||||||
|
if (seen.has(entry.sourceUrl)) return false;
|
||||||
|
seen.add(entry.sourceUrl);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
logData('Unique entries (after dedup)', unique.length);
|
||||||
|
|
||||||
|
// Format as Knowledge Base
|
||||||
|
let context = '## KNOWLEDGE BASE (Verified Sources)\n\n';
|
||||||
|
let currentLength = context.length;
|
||||||
|
const avgCharsPerToken = 4;
|
||||||
|
const maxChars = maxTokens * avgCharsPerToken;
|
||||||
|
logData('Max chars allowed', maxChars);
|
||||||
|
|
||||||
|
unique.forEach((entry, idx) => {
|
||||||
|
const snippet = entry.content.length > 1500
|
||||||
|
? entry.content.substring(0, 1500) + '...'
|
||||||
|
: entry.content;
|
||||||
|
|
||||||
|
const block = `[ID: ${idx + 1}] **${entry.title}**\nURL: ${entry.sourceUrl}\n${snippet}\n\n---\n\n`;
|
||||||
|
|
||||||
|
if (currentLength + block.length < maxChars) {
|
||||||
|
context += block;
|
||||||
|
currentLength += block.length;
|
||||||
|
} else {
|
||||||
|
logStep(` → Skipping entry ${idx + 1} (would exceed max chars)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addLog({
|
||||||
|
stage: 'aggregation',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: `${kb.length} raw entries`,
|
||||||
|
parsedOutput: `${unique.length} unique entries, ${currentLength} chars`
|
||||||
|
});
|
||||||
|
|
||||||
|
logSuccess(`Knowledge Base formatted: ${unique.length} entries, ${currentLength} chars`);
|
||||||
|
return context;
|
||||||
|
}, [state.knowledgeBase]);
|
||||||
|
|
||||||
|
// --- OPTIMIZED CONTEXT WITH CONDENSATION ---
|
||||||
|
const getCuratedContextOptimized = useCallback((
|
||||||
|
query: string,
|
||||||
|
maxTokens: number = 12000,
|
||||||
|
options?: {
|
||||||
|
minRelevanceScore?: number;
|
||||||
|
autoSummarize?: boolean;
|
||||||
|
fallbackToSummarization?: boolean;
|
||||||
|
}
|
||||||
|
): { context: string; result: CondensationResult } => {
|
||||||
|
logSection('AGGREGATION (OPTIMIZED)');
|
||||||
|
const kb = state.knowledgeBase;
|
||||||
|
logData('Raw KB entries', kb.length);
|
||||||
|
logData('User Query', query);
|
||||||
|
logData('Max Tokens', maxTokens);
|
||||||
|
|
||||||
|
if (kb.length === 0) {
|
||||||
|
logStep('Knowledge Base is empty, skipping aggregation');
|
||||||
|
return {
|
||||||
|
context: '',
|
||||||
|
result: {
|
||||||
|
context: '',
|
||||||
|
totalTokens: 0,
|
||||||
|
originalTokens: 0,
|
||||||
|
compressionRatio: 0,
|
||||||
|
chunksUsed: 0,
|
||||||
|
chunksTotal: 0,
|
||||||
|
method: 'fallback',
|
||||||
|
sources: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by URL
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique = kb.filter(entry => {
|
||||||
|
if (seen.has(entry.sourceUrl)) return false;
|
||||||
|
seen.add(entry.sourceUrl);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
logData('Unique entries (after dedup)', unique.length);
|
||||||
|
|
||||||
|
// Condensar Knowledge Base
|
||||||
|
const condensationResult = condenseKnowledgeBase(unique, query, {
|
||||||
|
maxTokens,
|
||||||
|
minRelevanceScore: options?.minRelevanceScore ?? 0.1,
|
||||||
|
autoSummarize: options?.autoSummarize ?? true,
|
||||||
|
fallbackToSummarization: options?.fallbackToSummarization ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log detalhado do resultado
|
||||||
|
logData('Condensation Method', condensationResult.method);
|
||||||
|
logData('Original Tokens', condensationResult.originalTokens);
|
||||||
|
logData('Final Tokens', condensationResult.totalTokens);
|
||||||
|
logData('Compression Ratio', `${(condensationResult.compressionRatio * 100).toFixed(1)}%`);
|
||||||
|
logData('Chunks Used', `${condensationResult.chunksUsed} / ${condensationResult.chunksTotal}`);
|
||||||
|
|
||||||
|
// Log preview dos chunks selecionados
|
||||||
|
if (condensationResult.chunksUsed > 0) {
|
||||||
|
logStep('--- Selected Chunks Preview ---');
|
||||||
|
// O contexto já contém os chunks, vamos logar um preview
|
||||||
|
const preview = condensationResult.context.substring(0, 500);
|
||||||
|
logData('Context Preview', preview + (condensationResult.context.length > 500 ? '...' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log estatísticas por fonte
|
||||||
|
logStep('--- Source Statistics ---');
|
||||||
|
condensationResult.sources.forEach((source, idx) => {
|
||||||
|
logData(`Source ${idx + 1}`, `${source.title}: ${source.chunksUsed} chunks used`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validar contexto
|
||||||
|
const validation = validateCondensedContext(condensationResult, query);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
logError('Context validation failed', validation.warnings.join(', '));
|
||||||
|
} else if (validation.warnings.length > 0) {
|
||||||
|
validation.warnings.forEach(warning => {
|
||||||
|
logStep(`⚠️ Warning: ${warning}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog({
|
||||||
|
stage: 'aggregation',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: `${kb.length} raw entries, query: "${query}"`,
|
||||||
|
parsedOutput: {
|
||||||
|
method: condensationResult.method,
|
||||||
|
chunksUsed: condensationResult.chunksUsed,
|
||||||
|
chunksTotal: condensationResult.chunksTotal,
|
||||||
|
compressionRatio: condensationResult.compressionRatio,
|
||||||
|
totalTokens: condensationResult.totalTokens,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logSuccess(`Knowledge Base optimized: ${condensationResult.chunksUsed} chunks selected, ${condensationResult.totalTokens} tokens`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: condensationResult.context,
|
||||||
|
result: condensationResult,
|
||||||
|
};
|
||||||
|
}, [state.knowledgeBase]);
|
||||||
|
|
||||||
|
// --- VALIDATION ---
|
||||||
|
const validate = useCallback(async (model: string, userQuery: string) => {
|
||||||
|
logSection('VALIDATION');
|
||||||
|
logData('User Query', userQuery);
|
||||||
|
|
||||||
|
setState(s => ({ ...s, step: 'validating' }));
|
||||||
|
|
||||||
|
const context = getCuratedContext();
|
||||||
|
if (!context || context.length < 100) {
|
||||||
|
logError('Insufficient context for validation');
|
||||||
|
addLog({ stage: 'validation', timestamp: Date.now(), input: 'No context', error: 'Insufficient data' });
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt = DEEP_RESEARCH_PROMPTS.CONTEXT_CHECK
|
||||||
|
.replace('{{knowledgeBase}}', context)
|
||||||
|
.replace('{{userQuery}}', userQuery);
|
||||||
|
|
||||||
|
logStep('Sending validation prompt to LLM...');
|
||||||
|
logData('Prompt length', `${prompt.length} chars`);
|
||||||
|
|
||||||
|
const report = await callLLM(model, prompt);
|
||||||
|
|
||||||
|
logSuccess('Validation complete');
|
||||||
|
logData('Validation Report', report);
|
||||||
|
|
||||||
|
addLog({
|
||||||
|
stage: 'validation',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
input: prompt.substring(0, 200) + '...',
|
||||||
|
rawOutput: report,
|
||||||
|
parsedOutput: report
|
||||||
|
});
|
||||||
|
|
||||||
|
setState(s => ({ ...s, validationReport: report }));
|
||||||
|
return report;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logError('Validation failed', errorMessage);
|
||||||
|
setState(s => ({ ...s, step: 'error', error: 'Validation failed' }));
|
||||||
|
addLog({ stage: 'validation', timestamp: Date.now(), input: 'Validation', error: errorMessage });
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [getCuratedContext]);
|
||||||
|
|
||||||
|
// --- RESET ---
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
logStep('Resetting Deep Research state');
|
||||||
|
setState({
|
||||||
|
step: 'idle',
|
||||||
|
plan: [],
|
||||||
|
knowledgeBase: [],
|
||||||
|
validationReport: '',
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
decompose,
|
||||||
|
addToKnowledgeBase,
|
||||||
|
getCuratedContext,
|
||||||
|
getCuratedContextOptimized,
|
||||||
|
validate,
|
||||||
|
reset,
|
||||||
|
setStep: (step: DeepResearchStep) => setState(s => ({ ...s, step }))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { SystemSpecs } from '@/lib/recommendation';
|
import { SystemSpecs, GpuInfo } from '@/lib/recommendation';
|
||||||
|
|
||||||
export function useHardware() {
|
export function useHardware() {
|
||||||
const [specs, setSpecs] = useState<SystemSpecs | null>(null);
|
const [specs, setSpecs] = useState<SystemSpecs | null>(null);
|
||||||
@@ -23,6 +23,10 @@ export function useHardware() {
|
|||||||
fetchSpecs();
|
fetchSpecs();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { specs, loading };
|
return {
|
||||||
|
specs,
|
||||||
|
loading,
|
||||||
|
gpus: specs?.gpus || []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,3 +41,5 @@ export function useLocalModels() {
|
|||||||
return { models, loading, refresh: fetchModels, deleteModel };
|
return { models, loading, refresh: fetchModels, deleteModel };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface McpServerInfo {
|
||||||
|
name: string;
|
||||||
|
status: 'running' | 'stopped' | 'error';
|
||||||
|
pid?: number;
|
||||||
|
toolCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMcpServers() {
|
||||||
|
const [servers, setServers] = useState<McpServerInfo[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadServers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const serverStatuses = await invoke<McpServerInfo[]>('list_mcp_server_status');
|
||||||
|
|
||||||
|
// Get tool counts for each server
|
||||||
|
const allTools = await invoke<Array<{ server_name: string; tool: any }>>('get_all_mcp_tools');
|
||||||
|
|
||||||
|
// Count tools per server
|
||||||
|
const toolCounts = new Map<string, number>();
|
||||||
|
for (const toolInfo of allTools) {
|
||||||
|
toolCounts.set(toolInfo.server_name, (toolCounts.get(toolInfo.server_name) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich server info with tool counts
|
||||||
|
const enrichedServers = serverStatuses.map(server => ({
|
||||||
|
...server,
|
||||||
|
toolCount: toolCounts.get(server.name) || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setServers(enrichedServers);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load MCP servers:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load MCP servers');
|
||||||
|
setServers([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadServers();
|
||||||
|
|
||||||
|
// Refresh servers every 10 seconds
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadServers();
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadServers]);
|
||||||
|
|
||||||
|
// Get only running servers
|
||||||
|
const getRunningServers = useCallback((): McpServerInfo[] => {
|
||||||
|
return servers.filter(server => server.status === 'running');
|
||||||
|
}, [servers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
servers,
|
||||||
|
runningServers: getRunningServers(),
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadServers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface McpTool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
input_schema?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpToolInfo {
|
||||||
|
server_name: string;
|
||||||
|
tool: McpTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMcpTools() {
|
||||||
|
const [tools, setTools] = useState<McpToolInfo[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadTools = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const allTools = await invoke<McpToolInfo[]>('get_all_mcp_tools');
|
||||||
|
setTools(allTools);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load MCP tools:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load MCP tools');
|
||||||
|
setTools([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTools();
|
||||||
|
|
||||||
|
// Refresh tools every 30 seconds
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
loadTools();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [loadTools]);
|
||||||
|
|
||||||
|
// Generate tools context for prompt injection
|
||||||
|
const generateToolsContext = useCallback((availableTools: McpToolInfo[], selectedServers?: string[]): string => {
|
||||||
|
// Filter tools by selected servers if provided
|
||||||
|
let filteredTools = availableTools;
|
||||||
|
if (selectedServers && selectedServers.length > 0) {
|
||||||
|
filteredTools = availableTools.filter(tool => selectedServers.includes(tool.server_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredTools.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let context = '\n\n## Ferramentas MCP Disponíveis\n\n';
|
||||||
|
const header = selectedServers && selectedServers.length > 0
|
||||||
|
? `Você está usando os servidores MCP selecionados: ${selectedServers.join(', ')}.\n`
|
||||||
|
: 'Você tem acesso aos servidores MCP listados abaixo.\n';
|
||||||
|
context += `${header}\n`;
|
||||||
|
context += '### REGRAS OBRIGATÓRIAS PARA MCP:\n\n';
|
||||||
|
context += '1. **PRIORIZE RESULTADOS DE MCP**: Se você vir mensagens anteriores marcadas com `[MCP servidor/ferramenta]`, USE-AS como base da sua resposta.\n';
|
||||||
|
context += '2. **IDENTIFIQUE FONTES**: Quando há dados de MCP disponíveis, cite-os explicitamente (ex: "De acordo com a busca realizada...").\n';
|
||||||
|
context += '3. **TRATE ERROS**: Se MCP retornou erro (mensagens com ⚠️), informe ao usuário sobre a limitação de forma clara.\n';
|
||||||
|
context += '4. **SOLICITE FERRAMENTAS**: Use o formato `<tool_name>(arg1=valor1, arg2=valor2)` para solicitar execução de ferramentas.\n';
|
||||||
|
context += '5. **PROIBIDO**: NUNCA gere blocos `<metadata>`, JSON oculto ou texto como "Metadados:". Apenas texto visível.\n\n';
|
||||||
|
context += '**Nota**: O sistema executa MCPs automaticamente antes de você receber a mensagem do usuário. Verifique sempre se há resultados MCP nas mensagens anteriores.\n\n';
|
||||||
|
|
||||||
|
// Group tools by server
|
||||||
|
const toolsByServer = new Map<string, McpTool[]>();
|
||||||
|
for (const toolInfo of filteredTools) {
|
||||||
|
if (!toolsByServer.has(toolInfo.server_name)) {
|
||||||
|
toolsByServer.set(toolInfo.server_name, []);
|
||||||
|
}
|
||||||
|
toolsByServer.get(toolInfo.server_name)!.push(toolInfo.tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format tools for each server
|
||||||
|
for (const [serverName, serverTools] of toolsByServer.entries()) {
|
||||||
|
context += `### Servidor: ${serverName}\n\n`;
|
||||||
|
|
||||||
|
for (const tool of serverTools) {
|
||||||
|
context += `- **${tool.name}**: ${tool.description}\n`;
|
||||||
|
|
||||||
|
// Add input schema info if available
|
||||||
|
if (tool.input_schema) {
|
||||||
|
const schema = tool.input_schema;
|
||||||
|
if (schema.properties) {
|
||||||
|
context += ` - Parâmetros: `;
|
||||||
|
const params: string[] = [];
|
||||||
|
for (const [key, value] of Object.entries(schema.properties)) {
|
||||||
|
const prop = value as any;
|
||||||
|
params.push(`${key} (${prop.type || 'any'})`);
|
||||||
|
}
|
||||||
|
context += params.join(', ') + '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context += '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context += '### Como responder quando há dados MCP:\n\n';
|
||||||
|
context += '1. Verifique se há mensagens com `[MCP ...]` no histórico recente\n';
|
||||||
|
context += '2. Analise os dados retornados (mesmo que sejam erros)\n';
|
||||||
|
context += '3. Baseie sua resposta PRINCIPALMENTE nos dados MCP\n';
|
||||||
|
context += '4. Complemente com seu conhecimento apenas se necessário\n';
|
||||||
|
context += '5. Apresente tudo em texto limpo, sem metadados ou JSON oculto\n\n';
|
||||||
|
context += '**Lembre-se**: Resultados MCP são dados em tempo real e devem ter prioridade sobre seu conhecimento interno.\n';
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get tools context string for prompt injection
|
||||||
|
const getToolsContext = useCallback((selectedServers?: string[]): string => {
|
||||||
|
return generateToolsContext(tools, selectedServers);
|
||||||
|
}, [tools, generateToolsContext]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tools,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
loadTools,
|
||||||
|
generateToolsContext,
|
||||||
|
getToolsContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -7,10 +7,19 @@ export function useOllamaCheck() {
|
|||||||
const [status, setStatus] = useState<OllamaStatus>('checking');
|
const [status, setStatus] = useState<OllamaStatus>('checking');
|
||||||
|
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
// Don't set checking here if we want to silently revalidate,
|
setStatus('checking');
|
||||||
// but for initial load or explicit re-check it is fine.
|
try {
|
||||||
// Only set checking if it's not already in a known state to avoid flicker?
|
// Usar verificação completa que checa instalação E execução
|
||||||
// For now simple is better.
|
const result = await invoke<{
|
||||||
|
installed: boolean;
|
||||||
|
running: boolean;
|
||||||
|
status: string;
|
||||||
|
}>('check_ollama_full');
|
||||||
|
|
||||||
|
setStatus(result.status as OllamaStatus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check ollama:', error);
|
||||||
|
// Se falhar, tentar verificação individual como fallback
|
||||||
try {
|
try {
|
||||||
const installed = await invoke<boolean>('check_ollama_installed');
|
const installed = await invoke<boolean>('check_ollama_installed');
|
||||||
if (!installed) {
|
if (!installed) {
|
||||||
@@ -25,11 +34,11 @@ export function useOllamaCheck() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStatus('running');
|
setStatus('running');
|
||||||
} catch (error) {
|
} catch (fallbackError) {
|
||||||
console.error('Failed to check ollama:', error);
|
console.error('Fallback check also failed:', fallbackError);
|
||||||
// If invoke fails (e.g. backend not ready), we might want to handle gracefully
|
|
||||||
setStatus('not_installed');
|
setStatus('not_installed');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { downloadInstaller, getDownloadedInstallerPath, runInstaller, type OS } from '@/lib/download-utils';
|
||||||
|
|
||||||
|
interface DownloadState {
|
||||||
|
isDownloading: boolean;
|
||||||
|
downloadProgress: number;
|
||||||
|
downloadStatus: string;
|
||||||
|
downloadError: string | null;
|
||||||
|
filePath: string | null;
|
||||||
|
isInstalled: boolean;
|
||||||
|
isChecking: boolean;
|
||||||
|
checkStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOllamaDownload() {
|
||||||
|
const [state, setState] = useState<DownloadState>({
|
||||||
|
isDownloading: false,
|
||||||
|
downloadProgress: 0,
|
||||||
|
downloadStatus: '',
|
||||||
|
downloadError: null,
|
||||||
|
filePath: null,
|
||||||
|
isInstalled: false,
|
||||||
|
isChecking: false,
|
||||||
|
checkStatus: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const isCheckingRef = useRef(false);
|
||||||
|
|
||||||
|
// Escutar eventos de progresso do download
|
||||||
|
useEffect(() => {
|
||||||
|
const unlistenPromise = listen('installer-download-progress', (event: any) => {
|
||||||
|
const data = event.payload;
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
downloadProgress: data.progress || 0,
|
||||||
|
downloadStatus: data.status || '',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unlistenPromise.then(unlisten => unlisten());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDownload = async (os: OS) => {
|
||||||
|
setState({
|
||||||
|
isDownloading: true,
|
||||||
|
downloadProgress: 0,
|
||||||
|
downloadStatus: 'Iniciando download...',
|
||||||
|
downloadError: null,
|
||||||
|
filePath: null,
|
||||||
|
isInstalled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verificar se já foi baixado
|
||||||
|
const existingPath = await getDownloadedInstallerPath(os);
|
||||||
|
if (existingPath) {
|
||||||
|
setState({
|
||||||
|
isDownloading: false,
|
||||||
|
downloadProgress: 100,
|
||||||
|
downloadStatus: 'Download já concluído',
|
||||||
|
downloadError: null,
|
||||||
|
filePath: existingPath,
|
||||||
|
isInstalled: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fazer download
|
||||||
|
const filePath = await downloadInstaller(os);
|
||||||
|
setState({
|
||||||
|
isDownloading: false,
|
||||||
|
downloadProgress: 100,
|
||||||
|
downloadStatus: 'Download concluído',
|
||||||
|
downloadError: null,
|
||||||
|
filePath,
|
||||||
|
isInstalled: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setState({
|
||||||
|
isDownloading: false,
|
||||||
|
downloadProgress: 0,
|
||||||
|
downloadStatus: '',
|
||||||
|
downloadError: error instanceof Error ? error.message : 'Erro desconhecido durante download',
|
||||||
|
filePath: null,
|
||||||
|
isInstalled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (!state.filePath) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
downloadError: 'Nenhum instalador disponível',
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runInstaller(state.filePath);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isInstalled: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Iniciar verificação automática em segundo plano
|
||||||
|
startAutoCheck();
|
||||||
|
} catch (error) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkOllama = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const installed = await invoke<boolean>('check_ollama_installed');
|
||||||
|
if (!installed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const running = await invoke<boolean>('check_ollama_running');
|
||||||
|
return running;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao verificar Ollama:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAutoCheck = () => {
|
||||||
|
// Limpar intervalo anterior se existir
|
||||||
|
if (checkingIntervalRef.current) {
|
||||||
|
clearInterval(checkingIntervalRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCheckingRef.current) {
|
||||||
|
return; // Já está verificando
|
||||||
|
}
|
||||||
|
|
||||||
|
isCheckingRef.current = true;
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: true,
|
||||||
|
checkStatus: 'Verificando instalação do Ollama...',
|
||||||
|
}));
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 300; // 5 minutos (300 * 1 segundo)
|
||||||
|
|
||||||
|
checkingIntervalRef.current = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
const isRunning = await checkOllama();
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
// Ollama encontrado e rodando!
|
||||||
|
if (checkingIntervalRef.current) {
|
||||||
|
clearInterval(checkingIntervalRef.current);
|
||||||
|
checkingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
isCheckingRef.current = false;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: false,
|
||||||
|
checkStatus: 'Ollama encontrado e rodando!',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Emitir evento customizado para notificar o componente pai
|
||||||
|
window.dispatchEvent(new CustomEvent('ollama-installed'));
|
||||||
|
} else if (attempts >= maxAttempts) {
|
||||||
|
// Limite de tentativas atingido
|
||||||
|
if (checkingIntervalRef.current) {
|
||||||
|
clearInterval(checkingIntervalRef.current);
|
||||||
|
checkingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
isCheckingRef.current = false;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: false,
|
||||||
|
checkStatus: 'Verificação automática pausada. Use "Verificar Novamente" para continuar.',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
checkStatus: `Verificando... (${attempts}/${maxAttempts})`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, 1000); // Verificar a cada 1 segundo
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoCheck = () => {
|
||||||
|
if (checkingIntervalRef.current) {
|
||||||
|
clearInterval(checkingIntervalRef.current);
|
||||||
|
checkingIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
isCheckingRef.current = false;
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: false,
|
||||||
|
checkStatus: '',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limpar intervalo ao desmontar
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (checkingIntervalRef.current) {
|
||||||
|
clearInterval(checkingIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkExistingDownload = async (os: OS) => {
|
||||||
|
const existingPath = await getDownloadedInstallerPath(os);
|
||||||
|
if (existingPath) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
filePath: existingPath,
|
||||||
|
downloadProgress: 100,
|
||||||
|
downloadStatus: 'Download já concluído',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
stopAutoCheck();
|
||||||
|
setState({
|
||||||
|
isDownloading: false,
|
||||||
|
downloadProgress: 0,
|
||||||
|
downloadStatus: '',
|
||||||
|
downloadError: null,
|
||||||
|
filePath: null,
|
||||||
|
isInstalled: false,
|
||||||
|
isChecking: false,
|
||||||
|
checkStatus: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
handleDownload,
|
||||||
|
handleInstall,
|
||||||
|
checkExistingDownload,
|
||||||
|
startAutoCheck,
|
||||||
|
stopAutoCheck,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import type { OS } from '@/lib/download-utils';
|
||||||
|
|
||||||
|
export function useOperatingSystem() {
|
||||||
|
const [os, setOs] = useState<OS | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const detectOS = async () => {
|
||||||
|
try {
|
||||||
|
const detectedOS = await invoke<string>('get_operating_system');
|
||||||
|
// Mapear para o tipo OS
|
||||||
|
if (detectedOS === 'windows' || detectedOS === 'mac' || detectedOS === 'linux') {
|
||||||
|
setOs(detectedOS as OS);
|
||||||
|
} else {
|
||||||
|
// Fallback: tentar detectar via user agent
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
if (userAgent.includes('win')) {
|
||||||
|
setOs('windows');
|
||||||
|
} else if (userAgent.includes('mac')) {
|
||||||
|
setOs('mac');
|
||||||
|
} else if (userAgent.includes('linux')) {
|
||||||
|
setOs('linux');
|
||||||
|
} else {
|
||||||
|
setOs('windows'); // Default fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to detect OS:', error);
|
||||||
|
// Fallback: detectar via user agent
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
if (userAgent.includes('win')) {
|
||||||
|
setOs('windows');
|
||||||
|
} else if (userAgent.includes('mac')) {
|
||||||
|
setOs('mac');
|
||||||
|
} else if (userAgent.includes('linux')) {
|
||||||
|
setOs('linux');
|
||||||
|
} else {
|
||||||
|
setOs('windows'); // Default fallback
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
detectOS();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { os, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -68,3 +68,5 @@ export function usePromptGenerator() {
|
|||||||
return { generatePrompt, isGenerating };
|
return { generatePrompt, isGenerating };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
|
||||||
|
export interface PythonBootstrapProgress {
|
||||||
|
stage: string;
|
||||||
|
message: string;
|
||||||
|
progress: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PythonBootstrapState {
|
||||||
|
stage: string;
|
||||||
|
message: string;
|
||||||
|
progress: number | null;
|
||||||
|
isComplete: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePythonBootstrap() {
|
||||||
|
const [state, setState] = useState<PythonBootstrapState>({
|
||||||
|
stage: 'checking',
|
||||||
|
message: 'Verificando runtime Python...',
|
||||||
|
progress: null,
|
||||||
|
isComplete: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Verificar status inicial
|
||||||
|
const checkInitialStatus = async () => {
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const status = await invoke<{ ready: boolean; error: string | null }>('check_python_runtime_status');
|
||||||
|
|
||||||
|
if (status.ready) {
|
||||||
|
setState({
|
||||||
|
stage: 'done',
|
||||||
|
message: 'Runtime Python pronto',
|
||||||
|
progress: 1.0,
|
||||||
|
isComplete: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else if (status.error) {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: status.error || 'Erro desconhecido',
|
||||||
|
isComplete: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao verificar status inicial do Python:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkInitialStatus();
|
||||||
|
|
||||||
|
// Escutar eventos de progresso
|
||||||
|
const progressUnlisten = listen<PythonBootstrapProgress>('python-bootstrap-progress', (event) => {
|
||||||
|
const payload = event.payload;
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
stage: payload.stage,
|
||||||
|
message: payload.message,
|
||||||
|
progress: payload.progress ?? prev.progress,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escutar evento de conclusão
|
||||||
|
const completeUnlisten = listen('python-bootstrap-complete', () => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
stage: 'done',
|
||||||
|
message: 'Runtime Python pronto',
|
||||||
|
progress: 1.0,
|
||||||
|
isComplete: true,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escutar evento de erro
|
||||||
|
const errorUnlisten = listen<string>('python-bootstrap-error', (event) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error: event.payload || 'Erro desconhecido durante o bootstrap',
|
||||||
|
isComplete: false,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
progressUnlisten.then(unlisten => unlisten());
|
||||||
|
completeUnlisten.then(unlisten => unlisten());
|
||||||
|
errorUnlisten.then(unlisten => unlisten());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para gerar query de busca otimizada usando a LLM
|
||||||
|
*/
|
||||||
|
export function useQueryGenerator() {
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera uma query de busca otimizada baseada na pergunta do usuário
|
||||||
|
* Retorna 'NO_SEARCH' se não precisar de busca
|
||||||
|
*/
|
||||||
|
const generateQuery = useCallback(async (
|
||||||
|
userInput: string,
|
||||||
|
model: string
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!userInput || !userInput.trim()) {
|
||||||
|
return 'NO_SEARCH';
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prompt otimizado para gerar query
|
||||||
|
const prompt = `Baseado na pergunta do usuário abaixo, gere APENAS uma query de busca otimizada para o Google/DuckDuckGo.
|
||||||
|
|
||||||
|
Regras:
|
||||||
|
- Se a pergunta NÃO precisar de informações da web (ex: "Olá", "Como você está?", cálculos simples, perguntas sobre você), retorne EXATAMENTE: NO_SEARCH
|
||||||
|
- Se precisar de informações recentes, fatos, notícias, ou dados atualizados, gere uma query curta e direta (máximo 8 palavras)
|
||||||
|
- A query deve ser em português se a pergunta for em português
|
||||||
|
|
||||||
|
Pergunta do usuário: "${userInput}"
|
||||||
|
|
||||||
|
Query de busca (ou NO_SEARCH):`;
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:11434/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
|
||||||
|
num_predict: 50, // Limitar tokens para resposta rápida
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Falha ao gerar query');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const generatedText = (data.response || '').trim();
|
||||||
|
|
||||||
|
// Limpar resposta (remover aspas, pontos finais, etc)
|
||||||
|
let query = generatedText
|
||||||
|
.replace(/^["']|["']$/g, '') // Remover aspas
|
||||||
|
.replace(/\.$/, '') // Remover ponto final
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Verificar se é NO_SEARCH
|
||||||
|
if (query.toUpperCase().includes('NO_SEARCH') || query.length < 3) {
|
||||||
|
return 'NO_SEARCH';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao gerar query:', error);
|
||||||
|
// Em caso de erro, tentar usar a própria pergunta como query
|
||||||
|
// Mas apenas se parecer uma pergunta factual
|
||||||
|
const lowerInput = userInput.toLowerCase();
|
||||||
|
const needsSearch = lowerInput.includes('o que') ||
|
||||||
|
lowerInput.includes('quem') ||
|
||||||
|
lowerInput.includes('quando') ||
|
||||||
|
lowerInput.includes('onde') ||
|
||||||
|
lowerInput.includes('como') ||
|
||||||
|
lowerInput.includes('por que') ||
|
||||||
|
lowerInput.includes('qual') ||
|
||||||
|
lowerInput.includes('quais');
|
||||||
|
|
||||||
|
return needsSearch ? userInput : 'NO_SEARCH';
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { generateQuery, isGenerating };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { normalizeText } from '@/lib/text-normalization';
|
||||||
|
import { validateQuery, ValidationResult } from '@/lib/query-validation';
|
||||||
|
import { splitQuestions, SplitResult } from '@/lib/question-splitter';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
|
export type QueryIntent =
|
||||||
|
| 'factual'
|
||||||
|
| 'conversational'
|
||||||
|
| 'technical'
|
||||||
|
| 'opinion'
|
||||||
|
| 'calculation'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export interface PreprocessedQuery {
|
||||||
|
original: string;
|
||||||
|
normalized: string;
|
||||||
|
questions: string[];
|
||||||
|
intent: QueryIntent;
|
||||||
|
validation: ValidationResult;
|
||||||
|
shouldSearch: boolean;
|
||||||
|
splitResult: SplitResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreprocessOptions {
|
||||||
|
skipValidation?: boolean;
|
||||||
|
skipSplit?: boolean;
|
||||||
|
skipIntentClassification?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para pré-processamento de queries do usuário
|
||||||
|
* Orquestra normalização, validação, split e classificação de intenção
|
||||||
|
*/
|
||||||
|
export function useQueryPreprocessor() {
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pré-processa uma query do usuário
|
||||||
|
*
|
||||||
|
* @param text - Texto original da query
|
||||||
|
* @param options - Opções de pré-processamento
|
||||||
|
* @returns Query pré-processada
|
||||||
|
*/
|
||||||
|
const preprocess = useCallback(
|
||||||
|
async (
|
||||||
|
text: string,
|
||||||
|
options: PreprocessOptions = {}
|
||||||
|
): Promise<PreprocessedQuery> => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const original = text;
|
||||||
|
const normalized = normalizeText(text);
|
||||||
|
|
||||||
|
// 1. Validação
|
||||||
|
let validation: ValidationResult;
|
||||||
|
if (options.skipValidation) {
|
||||||
|
validation = {
|
||||||
|
isValid: true,
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
normalizedLength: normalized.length,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const preprocessingConfig = settings.queryPreprocessing || {
|
||||||
|
enabled: true,
|
||||||
|
minLength: 3,
|
||||||
|
maxLength: 2000,
|
||||||
|
autoSplitQuestions: true,
|
||||||
|
irrelevantPatterns: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
validation = validateQuery(text, {
|
||||||
|
minLength: preprocessingConfig.minLength,
|
||||||
|
maxLength: preprocessingConfig.maxLength,
|
||||||
|
irrelevantPatterns: preprocessingConfig.irrelevantPatterns.length > 0
|
||||||
|
? preprocessingConfig.irrelevantPatterns
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se inválida, retornar com erro
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return {
|
||||||
|
original,
|
||||||
|
normalized,
|
||||||
|
questions: [],
|
||||||
|
intent: 'unknown',
|
||||||
|
validation,
|
||||||
|
shouldSearch: false,
|
||||||
|
splitResult: {
|
||||||
|
questions: [],
|
||||||
|
originalText: original,
|
||||||
|
splitCount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Split de perguntas
|
||||||
|
let splitResult: SplitResult;
|
||||||
|
if (options.skipSplit) {
|
||||||
|
splitResult = {
|
||||||
|
questions: [original],
|
||||||
|
originalText: original,
|
||||||
|
splitCount: 1,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
splitResult = splitQuestions(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Classificação de intenção
|
||||||
|
let intent: QueryIntent = 'unknown';
|
||||||
|
if (!options.skipIntentClassification) {
|
||||||
|
try {
|
||||||
|
const intentStr = await invoke<string>('classify_intent', {
|
||||||
|
query: normalized,
|
||||||
|
});
|
||||||
|
intent = intentStr as QueryIntent;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao classificar intent:', error);
|
||||||
|
// Fallback para unknown
|
||||||
|
intent = 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Decidir se deve buscar
|
||||||
|
// Busca é necessária se:
|
||||||
|
// - Intent é factual ou technical (e webSearch está habilitado)
|
||||||
|
// - Intent é unknown (comportamento padrão: permitir busca)
|
||||||
|
const shouldSearch =
|
||||||
|
settings.webSearch.enabled &&
|
||||||
|
(intent === 'factual' ||
|
||||||
|
intent === 'technical' ||
|
||||||
|
intent === 'unknown');
|
||||||
|
|
||||||
|
return {
|
||||||
|
original,
|
||||||
|
normalized,
|
||||||
|
questions: splitResult.questions,
|
||||||
|
intent,
|
||||||
|
validation,
|
||||||
|
shouldSearch,
|
||||||
|
splitResult,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro no pré-processamento:', error);
|
||||||
|
// Retornar resultado com erro
|
||||||
|
return {
|
||||||
|
original: text,
|
||||||
|
normalized: normalizeText(text),
|
||||||
|
questions: [],
|
||||||
|
intent: 'unknown',
|
||||||
|
validation: {
|
||||||
|
isValid: false,
|
||||||
|
errors: ['Erro ao pré-processar query'],
|
||||||
|
warnings: [],
|
||||||
|
normalizedLength: 0,
|
||||||
|
},
|
||||||
|
shouldSearch: false,
|
||||||
|
splitResult: {
|
||||||
|
questions: [],
|
||||||
|
originalText: text,
|
||||||
|
splitCount: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settings]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preprocess,
|
||||||
|
isProcessing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -27,3 +27,5 @@ export function useSystemMonitor() {
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const showToast = useCallback(({ title, description, variant }: Toast) => {
|
||||||
|
// Simple implementation using console and alert for now
|
||||||
|
// Can be enhanced later with a proper toast component
|
||||||
|
if (variant === 'destructive') {
|
||||||
|
console.error(`[Toast] ${title}: ${description || ''}`);
|
||||||
|
alert(`Erro: ${title}\n${description || ''}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[Toast] ${title}: ${description || ''}`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toast: showToast,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { webSearchService, ScrapedContent, SearchConfig } from '@/services/webSearch';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
|
export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error';
|
||||||
|
|
||||||
|
export interface WebSearchState {
|
||||||
|
isEnabled: boolean;
|
||||||
|
status: SearchStatus;
|
||||||
|
currentQuery: string;
|
||||||
|
scrapedSources: ScrapedContent[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE: WebSearchState = {
|
||||||
|
isEnabled: true,
|
||||||
|
status: 'idle',
|
||||||
|
currentQuery: '',
|
||||||
|
scrapedSources: [],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para gerenciar estado do Web Search
|
||||||
|
*/
|
||||||
|
export function useWebSearch() {
|
||||||
|
const [state, setState] = useState<WebSearchState>(() => {
|
||||||
|
// Carregar preferência do localStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('webSearchEnabled');
|
||||||
|
return {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
isEnabled: saved !== null ? saved === 'true' : true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return DEFAULT_STATE;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ativa/desativa Web Search
|
||||||
|
*/
|
||||||
|
const setEnabled = useCallback((enabled: boolean) => {
|
||||||
|
setState(prev => ({ ...prev, isEnabled: enabled }));
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('webSearchEnabled', String(enabled));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executa busca
|
||||||
|
*/
|
||||||
|
const search = useCallback(async (query: string, limit: number = 3, excludedDomains: string[] = []): Promise<ScrapedContent[]> => {
|
||||||
|
if (!query || !query.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'searching',
|
||||||
|
currentQuery: query,
|
||||||
|
error: null,
|
||||||
|
scrapedSources: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Buscar configurações do store
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
|
||||||
|
// Construir SearchConfig a partir das configurações
|
||||||
|
const searchConfig: SearchConfig = {
|
||||||
|
maxConcurrentTabs: settings.webSearch.maxConcurrentTabs,
|
||||||
|
totalSourcesLimit: settings.webSearch.totalSourcesLimit,
|
||||||
|
categories: settings.webSearch.categories,
|
||||||
|
userCustomSites: settings.webSearch.userCustomSites,
|
||||||
|
excludedDomains: excludedDomains.length > 0 ? excludedDomains : settings.webSearch.excludedDomains,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fase 1: Buscando
|
||||||
|
setState(prev => ({ ...prev, status: 'searching' }));
|
||||||
|
|
||||||
|
// Fase 2: Scraping (o serviço já faz isso internamente)
|
||||||
|
setState(prev => ({ ...prev, status: 'scraping' }));
|
||||||
|
|
||||||
|
const results = await webSearchService.search(query, limit, excludedDomains, searchConfig);
|
||||||
|
|
||||||
|
// [DEBUG INJECTION START]
|
||||||
|
console.log('🌐 Debug: Web Search Service Results', {
|
||||||
|
count: results.length,
|
||||||
|
firstResultHasMarkdown: results[0] ? !!results[0].markdown : 'N/A',
|
||||||
|
firstResultMarkdownLen: results[0]?.markdown?.length || 0,
|
||||||
|
allResults: results.map((r, idx) => ({
|
||||||
|
index: idx,
|
||||||
|
url: r.url,
|
||||||
|
title: r.title,
|
||||||
|
hasMarkdown: !!r.markdown,
|
||||||
|
markdownLength: r.markdown?.length || 0,
|
||||||
|
markdownPreview: r.markdown?.substring(0, 150) || 'SEM MARKDOWN'
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
// [DEBUG INJECTION END]
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'completed',
|
||||||
|
scrapedSources: results,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido ao buscar';
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
error: errorMessage,
|
||||||
|
scrapedSources: [],
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipeline em duas etapas: metadados → scraping das top URLs
|
||||||
|
* Agora suporta timeout adaptativo por round
|
||||||
|
*/
|
||||||
|
const smartSearchRag = useCallback(async (query: string, limit: number = 3, round?: number): Promise<ScrapedContent[]> => {
|
||||||
|
if (!query || !query.trim()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'searching',
|
||||||
|
currentQuery: query,
|
||||||
|
error: null,
|
||||||
|
scrapedSources: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
const searchConfig: SearchConfig = {
|
||||||
|
maxConcurrentTabs: settings.webSearch.maxConcurrentTabs,
|
||||||
|
totalSourcesLimit: settings.webSearch.totalSourcesLimit,
|
||||||
|
categories: settings.webSearch.categories,
|
||||||
|
userCustomSites: settings.webSearch.userCustomSites,
|
||||||
|
excludedDomains: settings.webSearch.excludedDomains,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Etapa 1: metadados
|
||||||
|
setState(prev => ({ ...prev, status: 'searching' }));
|
||||||
|
const timeoutMs = settings.webSearch.timeout || 10000; // Reduzido de 15s para 10s
|
||||||
|
const { metadata, contents } = await webSearchService.smartSearchRag(
|
||||||
|
query,
|
||||||
|
limit,
|
||||||
|
searchConfig,
|
||||||
|
timeoutMs,
|
||||||
|
round || 1
|
||||||
|
);
|
||||||
|
|
||||||
|
// Etapa 2: scraping
|
||||||
|
setState(prev => ({ ...prev, status: 'scraping' }));
|
||||||
|
|
||||||
|
// [DEBUG] metadados coletados
|
||||||
|
console.log('🔎 Metadados coletados:', metadata);
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'completed',
|
||||||
|
scrapedSources: contents,
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Retornar resultados mesmo se parciais (não quebrar o fluxo)
|
||||||
|
return contents;
|
||||||
|
} catch (error) {
|
||||||
|
// Se houver erro, logar mas retornar array vazio (não quebrar o fluxo)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido no smartSearchRag';
|
||||||
|
console.warn('Erro em smartSearchRag (retornando vazio):', errorMessage);
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
error: errorMessage,
|
||||||
|
scrapedSources: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Retornar vazio em vez de throw para não quebrar o fluxo
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrai conteúdo de uma URL
|
||||||
|
*/
|
||||||
|
const extractUrl = useCallback(async (url: string): Promise<ScrapedContent> => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'scraping',
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await webSearchService.extractUrl(url);
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'completed',
|
||||||
|
error: null,
|
||||||
|
}));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido ao extrair URL';
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
status: 'error',
|
||||||
|
error: errorMessage,
|
||||||
|
}));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reseta o estado
|
||||||
|
*/
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState({
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
isEnabled: state.isEnabled, // Manter preferência
|
||||||
|
});
|
||||||
|
}, [state.isEnabled]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpa cache
|
||||||
|
*/
|
||||||
|
const clearCache = useCallback(() => {
|
||||||
|
webSearchService.clearCache();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
setEnabled,
|
||||||
|
search,
|
||||||
|
smartSearchRag,
|
||||||
|
extractUrl,
|
||||||
|
reset,
|
||||||
|
clearCache,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import type { ResearchEntry } from '@/hooks/use-deep-research';
|
||||||
|
|
||||||
|
export interface Chunk {
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
sourceIndex: number;
|
||||||
|
startIndex: number;
|
||||||
|
endIndex: number;
|
||||||
|
sourceUrl?: string;
|
||||||
|
sourceTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedContent {
|
||||||
|
original: string;
|
||||||
|
summarized?: string;
|
||||||
|
chunks: Chunk[];
|
||||||
|
keyFacts: string[];
|
||||||
|
totalTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessingOptions {
|
||||||
|
maxChunkSize?: number;
|
||||||
|
summarizeThreshold?: number;
|
||||||
|
maxSummaryLength?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<ProcessingOptions> = {
|
||||||
|
maxChunkSize: 1024,
|
||||||
|
summarizeThreshold: 5000,
|
||||||
|
maxSummaryLength: 2000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sumariza conteúdo markdown mantendo estrutura e informações-chave
|
||||||
|
*
|
||||||
|
* @param content - Conteúdo markdown a ser sumarizado
|
||||||
|
* @param maxLen - Comprimento máximo do sumário
|
||||||
|
* @returns Conteúdo sumarizado
|
||||||
|
*/
|
||||||
|
export function summarizeMarkdown(content: string, maxLen: number = 2000): string {
|
||||||
|
if (!content || content.length <= maxLen) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dividir por headings (###, ##, #)
|
||||||
|
const headingRegex = /^(#{1,3})\s+(.+)$/gm;
|
||||||
|
const sections: Array<{ level: number; title: string; content: string; startIndex: number }> = [];
|
||||||
|
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
const contentLines = content.split('\n');
|
||||||
|
|
||||||
|
// Encontrar todas as seções
|
||||||
|
while ((match = headingRegex.exec(content)) !== null) {
|
||||||
|
const level = match[1].length;
|
||||||
|
const title = match[2].trim();
|
||||||
|
const startIndex = content.substring(0, match.index).split('\n').length - 1;
|
||||||
|
|
||||||
|
// Encontrar conteúdo da seção (até próximo heading ou fim)
|
||||||
|
let endIndex = contentLines.length;
|
||||||
|
const nextHeadingRegex = /^(#{1,3})\s+/gm;
|
||||||
|
nextHeadingRegex.lastIndex = match.index + match[0].length;
|
||||||
|
const nextMatch = nextHeadingRegex.exec(content);
|
||||||
|
if (nextMatch) {
|
||||||
|
endIndex = content.substring(0, nextMatch.index).split('\n').length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionContent = contentLines.slice(startIndex + 1, endIndex).join('\n').trim();
|
||||||
|
|
||||||
|
sections.push({
|
||||||
|
level,
|
||||||
|
title,
|
||||||
|
content: sectionContent,
|
||||||
|
startIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastIndex = endIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não encontrou headings, dividir por parágrafos
|
||||||
|
if (sections.length === 0) {
|
||||||
|
const paragraphs = content.split(/\n\s*\n/).filter(p => p.trim().length > 0);
|
||||||
|
let summary = '';
|
||||||
|
|
||||||
|
for (const para of paragraphs) {
|
||||||
|
if (summary.length + para.length + 2 <= maxLen) {
|
||||||
|
summary += para.trim() + '\n\n';
|
||||||
|
} else {
|
||||||
|
// Adicionar parte do parágrafo se ainda houver espaço
|
||||||
|
const remaining = maxLen - summary.length - 2;
|
||||||
|
if (remaining > 100) {
|
||||||
|
summary += para.substring(0, remaining).trim() + '...';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary.trim() || content.substring(0, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir sumário priorizando seções de nível superior
|
||||||
|
let summary = '';
|
||||||
|
const sortedSections = sections.sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
|
for (const section of sortedSections) {
|
||||||
|
const sectionText = `## ${section.title}\n\n${section.content}\n\n`;
|
||||||
|
|
||||||
|
if (summary.length + sectionText.length <= maxLen) {
|
||||||
|
summary += sectionText;
|
||||||
|
} else {
|
||||||
|
// Adicionar parte da seção se ainda houver espaço
|
||||||
|
const remaining = maxLen - summary.length - 50; // 50 para heading
|
||||||
|
if (remaining > 100) {
|
||||||
|
const truncatedContent = section.content.substring(0, remaining).trim();
|
||||||
|
summary += `## ${section.title}\n\n${truncatedContent}...\n\n`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary.trim() || content.substring(0, maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Divide conteúdo markdown em chunks por seções
|
||||||
|
*
|
||||||
|
* @param markdown - Conteúdo markdown
|
||||||
|
* @param maxChunkSize - Tamanho máximo de cada chunk
|
||||||
|
* @returns Array de chunks
|
||||||
|
*/
|
||||||
|
export function chunkBySections(
|
||||||
|
markdown: string,
|
||||||
|
maxChunkSize: number = 1024
|
||||||
|
): Chunk[] {
|
||||||
|
if (!markdown || markdown.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Chunk[] = [];
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
|
||||||
|
// Dividir por headings primeiro
|
||||||
|
const headingRegex = /^(#{1,3})\s+(.+)$/;
|
||||||
|
let currentChunk: string[] = [];
|
||||||
|
let currentHeading = '';
|
||||||
|
let startIndex = 0;
|
||||||
|
let chunkIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const headingMatch = line.match(headingRegex);
|
||||||
|
|
||||||
|
if (headingMatch) {
|
||||||
|
// Se já temos um chunk, salvá-lo
|
||||||
|
if (currentChunk.length > 0) {
|
||||||
|
const chunkContent = currentChunk.join('\n');
|
||||||
|
if (chunkContent.trim().length > 0) {
|
||||||
|
chunks.push({
|
||||||
|
content: chunkContent,
|
||||||
|
score: 0, // Será calculado depois
|
||||||
|
sourceIndex: 0, // Será atribuído depois
|
||||||
|
startIndex: startIndex,
|
||||||
|
endIndex: i - 1,
|
||||||
|
});
|
||||||
|
chunkIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iniciar novo chunk
|
||||||
|
currentHeading = line;
|
||||||
|
currentChunk = [line];
|
||||||
|
startIndex = i;
|
||||||
|
} else {
|
||||||
|
currentChunk.push(line);
|
||||||
|
|
||||||
|
// Se chunk ficou muito grande, dividir por parágrafos
|
||||||
|
const currentContent = currentChunk.join('\n');
|
||||||
|
if (currentContent.length > maxChunkSize) {
|
||||||
|
// Salvar chunk atual (sem a linha atual)
|
||||||
|
const chunkWithoutLast = currentChunk.slice(0, -1).join('\n');
|
||||||
|
if (chunkWithoutLast.trim().length > 0) {
|
||||||
|
chunks.push({
|
||||||
|
content: chunkWithoutLast,
|
||||||
|
score: 0,
|
||||||
|
sourceIndex: 0,
|
||||||
|
startIndex: startIndex,
|
||||||
|
endIndex: i - 1,
|
||||||
|
});
|
||||||
|
chunkIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iniciar novo chunk com a linha atual
|
||||||
|
currentChunk = [currentHeading, line].filter(Boolean);
|
||||||
|
startIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar último chunk
|
||||||
|
if (currentChunk.length > 0) {
|
||||||
|
const chunkContent = currentChunk.join('\n');
|
||||||
|
if (chunkContent.trim().length > 0) {
|
||||||
|
chunks.push({
|
||||||
|
content: chunkContent,
|
||||||
|
score: 0,
|
||||||
|
sourceIndex: 0,
|
||||||
|
startIndex: startIndex,
|
||||||
|
endIndex: lines.length - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não encontrou headings, dividir por parágrafos
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
const paragraphs = markdown.split(/\n\s*\n/).filter(p => p.trim().length > 0);
|
||||||
|
let currentChunkContent = '';
|
||||||
|
let paraStartIndex = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < paragraphs.length; i++) {
|
||||||
|
const para = paragraphs[i].trim();
|
||||||
|
|
||||||
|
if (currentChunkContent.length + para.length + 2 <= maxChunkSize) {
|
||||||
|
currentChunkContent += (currentChunkContent ? '\n\n' : '') + para;
|
||||||
|
} else {
|
||||||
|
// Salvar chunk atual
|
||||||
|
if (currentChunkContent.length > 0) {
|
||||||
|
chunks.push({
|
||||||
|
content: currentChunkContent,
|
||||||
|
score: 0,
|
||||||
|
sourceIndex: 0,
|
||||||
|
startIndex: paraStartIndex,
|
||||||
|
endIndex: i - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iniciar novo chunk
|
||||||
|
currentChunkContent = para;
|
||||||
|
paraStartIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar último chunk
|
||||||
|
if (currentChunkContent.length > 0) {
|
||||||
|
chunks.push({
|
||||||
|
content: currentChunkContent,
|
||||||
|
score: 0,
|
||||||
|
sourceIndex: 0,
|
||||||
|
startIndex: paraStartIndex,
|
||||||
|
endIndex: paragraphs.length - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrai fatos-chave do conteúdo (entidades, datas, números)
|
||||||
|
*
|
||||||
|
* @param markdown - Conteúdo markdown
|
||||||
|
* @returns Array de fatos extraídos
|
||||||
|
*/
|
||||||
|
export function extractKeyFacts(markdown: string): string[] {
|
||||||
|
if (!markdown) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const facts: string[] = [];
|
||||||
|
const normalized = markdown.toLowerCase();
|
||||||
|
|
||||||
|
// Extrair datas (formato brasileiro: DD/MM/YYYY ou DD-MM-YYYY)
|
||||||
|
const dateRegex = /\b(\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})\b/g;
|
||||||
|
const dates = [...markdown.matchAll(dateRegex)];
|
||||||
|
dates.forEach(match => {
|
||||||
|
if (!facts.includes(match[0])) {
|
||||||
|
facts.push(match[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extrair anos (4 dígitos entre 1900-2100)
|
||||||
|
const yearRegex = /\b(19\d{2}|20[0-2]\d)\b/g;
|
||||||
|
const years = [...markdown.matchAll(yearRegex)];
|
||||||
|
years.forEach(match => {
|
||||||
|
if (!facts.includes(match[0])) {
|
||||||
|
facts.push(match[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extrair números relevantes (valores, estatísticas)
|
||||||
|
// Padrão: número seguido de unidade ou contexto
|
||||||
|
const numberPatterns = [
|
||||||
|
/\b(\d{1,3}(?:\.\d{3})*(?:,\d+)?)\s*(?:milhões?|milhões?|bilhões?|bilhões?|mil|km|metros?|metros?|anos?|anos?|pessoas?|habitantes?)/gi,
|
||||||
|
/\bR\$\s*(\d{1,3}(?:\.\d{3})*(?:,\d+)?)/gi,
|
||||||
|
/\b(\d{1,3}(?:\.\d{3})*(?:,\d+)?)\s*%/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
numberPatterns.forEach(pattern => {
|
||||||
|
const matches = [...markdown.matchAll(pattern)];
|
||||||
|
matches.forEach(match => {
|
||||||
|
const fact = match[0].trim();
|
||||||
|
if (fact.length > 0 && !facts.includes(fact)) {
|
||||||
|
facts.push(fact);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extrair locais (padrões comuns: "em [Local]", "de [Local]", "para [Local]")
|
||||||
|
const locationPatterns = [
|
||||||
|
/\b(?:em|de|para|no|na|nos|nas)\s+([A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+(?:\s+[A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+)*)/g,
|
||||||
|
/\b([A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+\s+(?:de\s+)?[A-ZÁÉÍÓÚÂÊÔ][a-záéíóúâêôãõç]+)\b/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
locationPatterns.forEach(pattern => {
|
||||||
|
const matches = [...markdown.matchAll(pattern)];
|
||||||
|
matches.forEach(match => {
|
||||||
|
const location = match[1]?.trim();
|
||||||
|
if (location && location.length > 3 && location.length < 50 && !facts.includes(location)) {
|
||||||
|
// Filtrar palavras comuns que não são locais
|
||||||
|
const commonWords = ['o', 'a', 'os', 'as', 'um', 'uma', 'de', 'da', 'do', 'das', 'dos'];
|
||||||
|
if (!commonWords.includes(location.toLowerCase())) {
|
||||||
|
facts.push(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limitar a 20 fatos mais relevantes
|
||||||
|
return facts.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processa conteúdo: sumariza, extrai fatos e faz chunking
|
||||||
|
*
|
||||||
|
* @param content - Conteúdo original
|
||||||
|
* @param query - Query do usuário (para contexto)
|
||||||
|
* @param options - Opções de processamento
|
||||||
|
* @returns Conteúdo processado
|
||||||
|
*/
|
||||||
|
export function processContent(
|
||||||
|
content: string,
|
||||||
|
query: string = '',
|
||||||
|
options: ProcessingOptions = {}
|
||||||
|
): ProcessedContent {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
|
||||||
|
if (!content || content.length === 0) {
|
||||||
|
return {
|
||||||
|
original: content,
|
||||||
|
chunks: [],
|
||||||
|
keyFacts: [],
|
||||||
|
totalTokens: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sumarizar se muito longo
|
||||||
|
let processedContent = content;
|
||||||
|
let summarized: string | undefined;
|
||||||
|
|
||||||
|
if (content.length > opts.summarizeThreshold) {
|
||||||
|
summarized = summarizeMarkdown(content, opts.maxSummaryLength);
|
||||||
|
processedContent = summarized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrair fatos-chave
|
||||||
|
const keyFacts = extractKeyFacts(content);
|
||||||
|
|
||||||
|
// Fazer chunking
|
||||||
|
const chunks = chunkBySections(processedContent, opts.maxChunkSize);
|
||||||
|
|
||||||
|
// Atribuir sourceIndex aos chunks (todos do mesmo source por enquanto)
|
||||||
|
chunks.forEach((chunk, idx) => {
|
||||||
|
chunk.sourceIndex = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular tokens totais (aproximação: 1 token ≈ 4 caracteres)
|
||||||
|
const totalTokens = Math.ceil(processedContent.length / 4);
|
||||||
|
|
||||||
|
return {
|
||||||
|
original: content,
|
||||||
|
summarized,
|
||||||
|
chunks,
|
||||||
|
keyFacts,
|
||||||
|
totalTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import type { ResearchEntry } from '@/hooks/use-deep-research';
|
||||||
|
import type { QueryContext } from './contextual-analyzer';
|
||||||
|
import { chatLog } from './terminal-logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fato extraído de uma fonte
|
||||||
|
*/
|
||||||
|
export interface ExtractedFact {
|
||||||
|
fact: string;
|
||||||
|
sourceId: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
sourceTitle: string;
|
||||||
|
confidence: number; // 0-1
|
||||||
|
entities?: string[]; // Entidades mencionadas no fato
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resultado da unificação de contexto
|
||||||
|
*/
|
||||||
|
export interface UnifiedContext {
|
||||||
|
unifiedText: string;
|
||||||
|
keyFacts: ExtractedFact[];
|
||||||
|
contradictions: Array<{
|
||||||
|
fact1: ExtractedFact;
|
||||||
|
fact2: ExtractedFact;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
sources: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
factsCount: number;
|
||||||
|
}>;
|
||||||
|
totalFacts: number;
|
||||||
|
uniqueFacts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt para extração de fatos usando LLM
|
||||||
|
*/
|
||||||
|
const FACT_EXTRACTION_PROMPT = `Você é um especialista em extração de fatos e análise de informações.
|
||||||
|
|
||||||
|
Analise o conteúdo abaixo e extraia os fatos principais relevantes para responder à query do usuário.
|
||||||
|
|
||||||
|
## QUERY DO USUÁRIO
|
||||||
|
"{{query}}"
|
||||||
|
|
||||||
|
## CONTEXTO ANALISADO
|
||||||
|
- Intenção: {{intent}}
|
||||||
|
- Tópicos: {{topics}}
|
||||||
|
- Entidades: {{entities}}
|
||||||
|
|
||||||
|
## CONTEÚDO DA FONTE
|
||||||
|
Título: {{title}}
|
||||||
|
URL: {{url}}
|
||||||
|
|
||||||
|
{{content}}
|
||||||
|
|
||||||
|
## INSTRUÇÕES
|
||||||
|
|
||||||
|
Extraia os fatos principais do conteúdo acima que são relevantes para responder à query do usuário.
|
||||||
|
|
||||||
|
Para cada fato:
|
||||||
|
- Seja específico e factual
|
||||||
|
- Inclua entidades mencionadas (pessoas, lugares, datas, conceitos)
|
||||||
|
- Mantenha o contexto necessário para entender o fato
|
||||||
|
- Evite informações genéricas ou óbvias
|
||||||
|
|
||||||
|
## FORMATO DE SAÍDA
|
||||||
|
|
||||||
|
Retorne APENAS um JSON válido com um array de fatos:
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"fact": "Fato específico extraído do conteúdo",
|
||||||
|
"entities": ["entidade1", "entidade2"],
|
||||||
|
"confidence": 0.9
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
IMPORTANTE:
|
||||||
|
- Retorne APENAS o JSON, sem markdown, sem explicações
|
||||||
|
- Confidence deve ser entre 0 e 1 (0.9 = muito confiável, 0.5 = incerto)
|
||||||
|
- Extraia 3-8 fatos principais, não mais
|
||||||
|
- Fatos devem ser independentes e específicos`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrai fatos principais de uma entrada de pesquisa
|
||||||
|
*/
|
||||||
|
export async function extractKeyFacts(
|
||||||
|
entry: ResearchEntry,
|
||||||
|
query: string,
|
||||||
|
context: QueryContext | null,
|
||||||
|
model: string
|
||||||
|
): Promise<ExtractedFact[]> {
|
||||||
|
try {
|
||||||
|
const entitiesStr = context?.entities.map(e => `${e.type}: ${e.value}`).join(', ') || 'nenhuma';
|
||||||
|
const topicsStr = context?.topics.join(', ') || 'nenhum';
|
||||||
|
const intentStr = context?.intent || 'unknown';
|
||||||
|
|
||||||
|
const prompt = FACT_EXTRACTION_PROMPT
|
||||||
|
.replace('{{query}}', query)
|
||||||
|
.replace('{{intent}}', intentStr)
|
||||||
|
.replace('{{topics}}', topicsStr)
|
||||||
|
.replace('{{entities}}', entitiesStr)
|
||||||
|
.replace('{{title}}', entry.title)
|
||||||
|
.replace('{{url}}', entry.sourceUrl)
|
||||||
|
.replace('{{content}}', entry.content.substring(0, 4000)); // Limitar tamanho
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:11434/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
temperature: 0.3,
|
||||||
|
num_predict: 600,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const rawResponse = (data.response || '').trim();
|
||||||
|
|
||||||
|
// Limpar e extrair JSON
|
||||||
|
let jsonText = rawResponse
|
||||||
|
.replace(/^```json\s*/i, '')
|
||||||
|
.replace(/^```\s*/i, '')
|
||||||
|
.replace(/```\s*$/i, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const jsonMatch = jsonText.match(/\[[\s\S]*\]/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
jsonText = jsonMatch[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const facts = JSON.parse(jsonText) as Array<{
|
||||||
|
fact: string;
|
||||||
|
entities?: string[];
|
||||||
|
confidence: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
return facts.map(f => ({
|
||||||
|
fact: f.fact,
|
||||||
|
sourceId: entry.id,
|
||||||
|
sourceUrl: entry.sourceUrl,
|
||||||
|
sourceTitle: entry.title,
|
||||||
|
confidence: f.confidence || 0.7,
|
||||||
|
entities: f.entities || [],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
chatLog.warn(`[ContextUnifier] Erro ao extrair fatos de ${entry.title}:`, error);
|
||||||
|
// Fallback: criar fato básico do título
|
||||||
|
return [{
|
||||||
|
fact: entry.title,
|
||||||
|
sourceId: entry.id,
|
||||||
|
sourceUrl: entry.sourceUrl,
|
||||||
|
sourceTitle: entry.title,
|
||||||
|
confidence: 0.5,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta contradições entre fatos
|
||||||
|
*/
|
||||||
|
function detectContradictions(facts: ExtractedFact[]): Array<{
|
||||||
|
fact1: ExtractedFact;
|
||||||
|
fact2: ExtractedFact;
|
||||||
|
description: string;
|
||||||
|
}> {
|
||||||
|
const contradictions: Array<{
|
||||||
|
fact1: ExtractedFact;
|
||||||
|
fact2: ExtractedFact;
|
||||||
|
description: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Comparar fatos por similaridade de entidades
|
||||||
|
for (let i = 0; i < facts.length; i++) {
|
||||||
|
for (let j = i + 1; j < facts.length; j++) {
|
||||||
|
const fact1 = facts[i];
|
||||||
|
const fact2 = facts[j];
|
||||||
|
|
||||||
|
// Verificar se mencionam as mesmas entidades
|
||||||
|
const sharedEntities = fact1.entities?.filter(e =>
|
||||||
|
fact2.entities?.includes(e)
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
if (sharedEntities.length > 0) {
|
||||||
|
// Verificar se os fatos são contraditórios (heurística simples)
|
||||||
|
const fact1Lower = fact1.fact.toLowerCase();
|
||||||
|
const fact2Lower = fact2.fact.toLowerCase();
|
||||||
|
|
||||||
|
// Palavras que indicam contradição
|
||||||
|
const contradictionIndicators = [
|
||||||
|
fact1Lower.includes('não') && fact2Lower.includes('sim'),
|
||||||
|
fact1Lower.includes('sim') && fact2Lower.includes('não'),
|
||||||
|
fact1Lower.includes('falso') && !fact2Lower.includes('falso'),
|
||||||
|
!fact1Lower.includes('falso') && fact2Lower.includes('falso'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (contradictionIndicators.some(Boolean)) {
|
||||||
|
contradictions.push({
|
||||||
|
fact1,
|
||||||
|
fact2,
|
||||||
|
description: `Possível contradição sobre ${sharedEntities.join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contradictions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrupa fatos similares
|
||||||
|
*/
|
||||||
|
function groupSimilarFacts(facts: ExtractedFact[]): Map<string, ExtractedFact[]> {
|
||||||
|
const groups = new Map<string, ExtractedFact[]>();
|
||||||
|
|
||||||
|
for (const fact of facts) {
|
||||||
|
// Criar chave baseada em entidades principais
|
||||||
|
const key = fact.entities?.slice(0, 2).join('|') || fact.fact.substring(0, 50);
|
||||||
|
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, []);
|
||||||
|
}
|
||||||
|
groups.get(key)!.push(fact);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unifica contexto de múltiplas fontes
|
||||||
|
*/
|
||||||
|
export async function unifyContext(
|
||||||
|
entries: ResearchEntry[],
|
||||||
|
query: string,
|
||||||
|
context: QueryContext | null,
|
||||||
|
model: string
|
||||||
|
): Promise<UnifiedContext> {
|
||||||
|
chatLog.info(`[ContextUnifier] Unificando contexto de ${entries.length} fontes`);
|
||||||
|
|
||||||
|
// Extrair fatos de todas as fontes
|
||||||
|
const allFacts: ExtractedFact[] = [];
|
||||||
|
const factPromises = entries.map(entry => extractKeyFacts(entry, query, context, model));
|
||||||
|
const factArrays = await Promise.all(factPromises);
|
||||||
|
|
||||||
|
for (const facts of factArrays) {
|
||||||
|
allFacts.push(...facts);
|
||||||
|
}
|
||||||
|
|
||||||
|
chatLog.info(`[ContextUnifier] Extraídos ${allFacts.length} fatos de ${entries.length} fontes`);
|
||||||
|
|
||||||
|
// Agrupar fatos similares
|
||||||
|
const factGroups = groupSimilarFacts(allFacts);
|
||||||
|
|
||||||
|
// Detectar contradições
|
||||||
|
const contradictions = detectContradictions(allFacts);
|
||||||
|
|
||||||
|
if (contradictions.length > 0) {
|
||||||
|
chatLog.warn(`[ContextUnifier] Detectadas ${contradictions.length} possíveis contradições`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar texto unificado
|
||||||
|
const unifiedSections: string[] = [];
|
||||||
|
const sourceMap = new Map<string, { title: string; url: string; factsCount: number }>();
|
||||||
|
|
||||||
|
// Agrupar por fonte
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryFacts = allFacts.filter(f => f.sourceId === entry.id);
|
||||||
|
|
||||||
|
if (entryFacts.length > 0) {
|
||||||
|
sourceMap.set(entry.id, {
|
||||||
|
title: entry.title,
|
||||||
|
url: entry.sourceUrl,
|
||||||
|
factsCount: entryFacts.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
unifiedSections.push(`## ${entry.title}`);
|
||||||
|
unifiedSections.push(`Fonte: ${entry.sourceUrl}\n`);
|
||||||
|
|
||||||
|
// Adicionar fatos principais
|
||||||
|
entryFacts
|
||||||
|
.sort((a, b) => b.confidence - a.confidence)
|
||||||
|
.slice(0, 5) // Top 5 fatos por fonte
|
||||||
|
.forEach(fact => {
|
||||||
|
unifiedSections.push(`- ${fact.fact}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
unifiedSections.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unifiedText = unifiedSections.join('\n');
|
||||||
|
|
||||||
|
// Remover duplicatas de fatos (mesmo texto)
|
||||||
|
const uniqueFacts = Array.from(
|
||||||
|
new Map(allFacts.map(f => [f.fact.toLowerCase(), f])).values()
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
unifiedText,
|
||||||
|
keyFacts: allFacts.sort((a, b) => b.confidence - a.confidence),
|
||||||
|
contradictions,
|
||||||
|
sources: Array.from(sourceMap.values()),
|
||||||
|
totalFacts: allFacts.length,
|
||||||
|
uniqueFacts: uniqueFacts.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { chatLog } from './terminal-logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de intenção da query
|
||||||
|
*/
|
||||||
|
export type QueryIntent =
|
||||||
|
| 'factual' // Busca por fatos específicos
|
||||||
|
| 'comparison' // Comparação entre entidades
|
||||||
|
| 'how-to' // Instruções ou tutoriais
|
||||||
|
| 'definition' // Definição de conceito
|
||||||
|
| 'list' // Listagem de itens
|
||||||
|
| 'opinion' // Opiniões ou análises
|
||||||
|
| 'temporal' // Informações temporais (quando, histórico)
|
||||||
|
| 'causal' // Causa e efeito (por que, como funciona)
|
||||||
|
| 'unknown'; // Não identificado
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entidade extraída da query
|
||||||
|
*/
|
||||||
|
export interface ExtractedEntity {
|
||||||
|
type: 'person' | 'location' | 'date' | 'concept' | 'organization' | 'event' | 'product' | 'other';
|
||||||
|
value: string;
|
||||||
|
confidence: number; // 0-1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relacionamento entre entidades
|
||||||
|
*/
|
||||||
|
export interface EntityRelationship {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
relation: string; // ex: "localizado em", "criado por", "relacionado com"
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexto temporal extraído
|
||||||
|
*/
|
||||||
|
export interface TemporalContext {
|
||||||
|
period?: string; // ex: "2024", "últimos 5 anos"
|
||||||
|
date?: string; // Data específica
|
||||||
|
relative?: string; // ex: "recente", "atual", "histórico"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contexto geográfico extraído
|
||||||
|
*/
|
||||||
|
export interface GeographicContext {
|
||||||
|
location?: string; // Cidade, estado, país
|
||||||
|
region?: string; // Região, continente
|
||||||
|
coordinates?: { lat: number; lng: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Análise contextual completa da query
|
||||||
|
*/
|
||||||
|
export interface QueryContext {
|
||||||
|
originalQuery: string;
|
||||||
|
intent: QueryIntent;
|
||||||
|
entities: ExtractedEntity[];
|
||||||
|
topics: string[]; // Tópicos principais identificados
|
||||||
|
relationships: EntityRelationship[]; // Relacionamentos entre entidades
|
||||||
|
implicitQuestions: string[]; // Perguntas implícitas na query
|
||||||
|
temporalContext?: TemporalContext;
|
||||||
|
geographicContext?: GeographicContext;
|
||||||
|
keywords: string[]; // Palavras-chave importantes
|
||||||
|
synonyms: string[]; // Sinônimos relevantes
|
||||||
|
domain?: string; // Domínio (ex: "acadêmico", "tecnologia", "saúde")
|
||||||
|
complexity: 'simple' | 'moderate' | 'complex';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt para análise contextual usando LLM
|
||||||
|
*/
|
||||||
|
const CONTEXTUAL_ANALYSIS_PROMPT = `Você é um especialista em análise semântica e extração de contexto de queries de busca.
|
||||||
|
|
||||||
|
Analise a query abaixo e extraia TODAS as informações contextuais relevantes.
|
||||||
|
|
||||||
|
Query: "{{query}}"
|
||||||
|
|
||||||
|
## INSTRUÇÕES
|
||||||
|
|
||||||
|
1. **INTENÇÃO**: Identifique o tipo de intenção (factual, comparison, how-to, definition, list, opinion, temporal, causal)
|
||||||
|
2. **ENTIDADES**: Extraia todas as entidades mencionadas (pessoas, lugares, datas, conceitos, organizações, eventos, produtos)
|
||||||
|
3. **TÓPICOS**: Identifique os tópicos principais abordados
|
||||||
|
4. **RELACIONAMENTOS**: Identifique relacionamentos implícitos entre entidades
|
||||||
|
5. **PERGUNTAS IMPLÍCITAS**: Quais perguntas relacionadas estão implícitas?
|
||||||
|
6. **CONTEXTO TEMPORAL**: Há referências a tempo? (datas, períodos, "recente", "atual", "histórico")
|
||||||
|
7. **CONTEXTO GEOGRÁFICO**: Há referências a localização? (cidades, estados, países, regiões)
|
||||||
|
8. **PALAVRAS-CHAVE**: Identifique palavras-chave importantes (remover stop words)
|
||||||
|
9. **SINÔNIMOS**: Liste sinônimos relevantes para os termos principais
|
||||||
|
10. **DOMÍNIO**: Identifique o domínio da query (acadêmico, tecnologia, saúde, negócios, etc.)
|
||||||
|
11. **COMPLEXIDADE**: Avalie a complexidade (simple, moderate, complex)
|
||||||
|
|
||||||
|
## FORMATO DE SAÍDA
|
||||||
|
|
||||||
|
Retorne APENAS um JSON válido no seguinte formato:
|
||||||
|
|
||||||
|
{
|
||||||
|
"intent": "list",
|
||||||
|
"entities": [
|
||||||
|
{"type": "concept", "value": "resultados acadêmicos", "confidence": 0.9},
|
||||||
|
{"type": "concept", "value": "prêmios", "confidence": 0.9}
|
||||||
|
],
|
||||||
|
"topics": ["pesquisa científica", "reconhecimento acadêmico", "premiações"],
|
||||||
|
"relationships": [
|
||||||
|
{"from": "resultados acadêmicos", "to": "prêmios", "relation": "recebem", "confidence": 0.8}
|
||||||
|
],
|
||||||
|
"implicitQuestions": [
|
||||||
|
"Quais foram os prêmios acadêmicos mais recentes?",
|
||||||
|
"Quem recebeu prêmios acadêmicos?"
|
||||||
|
],
|
||||||
|
"temporalContext": {"relative": "recente", "period": "2024"},
|
||||||
|
"geographicContext": null,
|
||||||
|
"keywords": ["resultados", "acadêmicos", "premiados", "últimos"],
|
||||||
|
"synonyms": ["pesquisas", "estudos", "trabalhos científicos", "reconhecimento", "distinções"],
|
||||||
|
"domain": "acadêmico",
|
||||||
|
"complexity": "moderate"
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPORTANTE:
|
||||||
|
- Retorne APENAS o JSON, sem markdown, sem explicações
|
||||||
|
- Use null para campos não aplicáveis
|
||||||
|
- Confidence deve ser entre 0 e 1
|
||||||
|
- Preserve nomes próprios e termos técnicos na forma original`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analisa o contexto de uma query usando LLM
|
||||||
|
*
|
||||||
|
* @param query - Query original do usuário
|
||||||
|
* @param model - Modelo LLM a ser usado
|
||||||
|
* @returns Análise contextual completa
|
||||||
|
*/
|
||||||
|
export async function analyzeQueryContext(
|
||||||
|
query: string,
|
||||||
|
model: string
|
||||||
|
): Promise<QueryContext> {
|
||||||
|
if (!query || !query.trim()) {
|
||||||
|
return {
|
||||||
|
originalQuery: query,
|
||||||
|
intent: 'unknown',
|
||||||
|
entities: [],
|
||||||
|
topics: [],
|
||||||
|
relationships: [],
|
||||||
|
implicitQuestions: [],
|
||||||
|
keywords: [],
|
||||||
|
synonyms: [],
|
||||||
|
complexity: 'simple',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
chatLog.info(`[ContextualAnalysis] Analisando contexto da query: "${query}"`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const prompt = CONTEXTUAL_ANALYSIS_PROMPT.replace('{{query}}', query);
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:11434/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
temperature: 0.3, // Baixa temperatura para análise mais determinística
|
||||||
|
num_predict: 800, // Espaço suficiente para JSON completo
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const rawResponse = (data.response || '').trim();
|
||||||
|
|
||||||
|
// Limpar resposta (remover markdown code blocks se houver)
|
||||||
|
let jsonText = rawResponse
|
||||||
|
.replace(/^```json\s*/i, '')
|
||||||
|
.replace(/^```\s*/i, '')
|
||||||
|
.replace(/```\s*$/i, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Tentar extrair JSON se houver texto antes/depois
|
||||||
|
const jsonMatch = jsonText.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
jsonText = jsonMatch[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysis = JSON.parse(jsonText) as Partial<QueryContext>;
|
||||||
|
|
||||||
|
// Validar e preencher campos obrigatórios
|
||||||
|
const context: QueryContext = {
|
||||||
|
originalQuery: query,
|
||||||
|
intent: (analysis.intent as QueryIntent) || 'unknown',
|
||||||
|
entities: analysis.entities || [],
|
||||||
|
topics: analysis.topics || [],
|
||||||
|
relationships: analysis.relationships || [],
|
||||||
|
implicitQuestions: analysis.implicitQuestions || [],
|
||||||
|
temporalContext: analysis.temporalContext,
|
||||||
|
geographicContext: analysis.geographicContext,
|
||||||
|
keywords: analysis.keywords || [],
|
||||||
|
synonyms: analysis.synonyms || [],
|
||||||
|
domain: analysis.domain,
|
||||||
|
complexity: analysis.complexity || 'moderate',
|
||||||
|
};
|
||||||
|
|
||||||
|
chatLog.success(`[ContextualAnalysis] Contexto extraído: ${context.intent}, ${context.entities.length} entidades, ${context.topics.length} tópicos`);
|
||||||
|
|
||||||
|
return context;
|
||||||
|
} catch (error) {
|
||||||
|
chatLog.error(`[ContextualAnalysis] Erro ao analisar contexto:`, error);
|
||||||
|
|
||||||
|
// Fallback: análise básica usando heurísticas
|
||||||
|
return createFallbackContext(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cria contexto básico usando heurísticas quando a análise LLM falha
|
||||||
|
*/
|
||||||
|
function createFallbackContext(query: string): QueryContext {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
// Detectar intenção básica
|
||||||
|
let intent: QueryIntent = 'factual';
|
||||||
|
if (lowerQuery.includes('como') || lowerQuery.includes('tutorial')) {
|
||||||
|
intent = 'how-to';
|
||||||
|
} else if (lowerQuery.includes('o que é') || lowerQuery.includes('definição')) {
|
||||||
|
intent = 'definition';
|
||||||
|
} else if (lowerQuery.includes('quais') || lowerQuery.includes('lista')) {
|
||||||
|
intent = 'list';
|
||||||
|
} else if (lowerQuery.includes('comparar') || lowerQuery.includes('vs')) {
|
||||||
|
intent = 'comparison';
|
||||||
|
} else if (lowerQuery.includes('por que') || lowerQuery.includes('causa')) {
|
||||||
|
intent = 'causal';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrair palavras-chave básicas
|
||||||
|
const stopWords = new Set([
|
||||||
|
'o', 'a', 'os', 'as', 'um', 'uma', 'de', 'da', 'do', 'das', 'dos',
|
||||||
|
'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'com', 'sem',
|
||||||
|
'que', 'qual', 'quais', 'onde', 'quando', 'como', 'porque',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const keywords = query
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(word => word.length > 2 && !stopWords.has(word));
|
||||||
|
|
||||||
|
// Detectar contexto temporal
|
||||||
|
let temporalContext: TemporalContext | undefined;
|
||||||
|
if (lowerQuery.includes('último') || lowerQuery.includes('recente') || lowerQuery.includes('atual')) {
|
||||||
|
temporalContext = { relative: 'recente' };
|
||||||
|
} else if (lowerQuery.includes('2024') || lowerQuery.includes('2023')) {
|
||||||
|
const yearMatch = query.match(/\b(20\d{2})\b/);
|
||||||
|
if (yearMatch) {
|
||||||
|
temporalContext = { period: yearMatch[1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalQuery: query,
|
||||||
|
intent,
|
||||||
|
entities: [],
|
||||||
|
topics: keywords.slice(0, 5), // Usar keywords como tópicos
|
||||||
|
relationships: [],
|
||||||
|
implicitQuestions: [],
|
||||||
|
temporalContext,
|
||||||
|
keywords,
|
||||||
|
synonyms: [],
|
||||||
|
complexity: keywords.length > 5 ? 'complex' : keywords.length > 2 ? 'moderate' : 'simple',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URLs oficiais dos instaladores Ollama
|
||||||
|
*/
|
||||||
|
export const OLLAMA_DOWNLOAD_URLS = {
|
||||||
|
windows: 'https://ollama.com/download/OllamaSetup.exe',
|
||||||
|
mac: 'https://ollama.com/download/Ollama-darwin.zip',
|
||||||
|
linux: 'https://ollama.com/install.sh',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nomes dos arquivos de fallback locais
|
||||||
|
*/
|
||||||
|
export const LOCAL_INSTALLER_FILES = {
|
||||||
|
windows: 'OllamaSetup.exe',
|
||||||
|
mac: 'Ollama-darwin.zip',
|
||||||
|
linux: 'install.sh',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type OS = 'windows' | 'mac' | 'linux';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se a URL oficial está disponível
|
||||||
|
*/
|
||||||
|
export async function checkOfficialUrlAvailable(os: OS): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const url = OLLAMA_DOWNLOAD_URLS[os];
|
||||||
|
return await invoke<boolean>('check_download_url', { url });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao verificar URL oficial:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se o instalador local existe
|
||||||
|
*/
|
||||||
|
export async function checkLocalInstallerExists(os: OS): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const filename = LOCAL_INSTALLER_FILES[os];
|
||||||
|
const path = await invoke<string | null>('get_local_installer_path', {
|
||||||
|
filename
|
||||||
|
});
|
||||||
|
return path !== null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao verificar instalador local:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faz download do instalador (oficial ou fallback)
|
||||||
|
* Retorna o caminho do arquivo baixado
|
||||||
|
*/
|
||||||
|
export async function downloadInstaller(os: OS): Promise<string> {
|
||||||
|
const url = OLLAMA_DOWNLOAD_URLS[os];
|
||||||
|
const filename = LOCAL_INSTALLER_FILES[os];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = await invoke<string>('download_installer', {
|
||||||
|
url,
|
||||||
|
filename
|
||||||
|
});
|
||||||
|
return filePath;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Falha ao baixar instalador: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se o instalador já foi baixado
|
||||||
|
*/
|
||||||
|
export async function getDownloadedInstallerPath(os: OS): Promise<string | null> {
|
||||||
|
const filename = LOCAL_INSTALLER_FILES[os];
|
||||||
|
try {
|
||||||
|
const path = await invoke<string | null>('get_downloaded_installer_path', {
|
||||||
|
filename
|
||||||
|
});
|
||||||
|
return path;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao verificar instalador baixado:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executa o instalador baixado
|
||||||
|
*/
|
||||||
|
export async function runInstaller(filePath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoke('run_installer', { filePath });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Falha ao executar instalador: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import type { ResearchEntry } from '@/hooks/use-deep-research';
|
||||||
|
import type { QueryContext } from './contextual-analyzer';
|
||||||
|
import { processContent, type Chunk, type ProcessedContent } from './content-condenser';
|
||||||
|
import { scoreAndSelectChunks } from './relevance-scorer';
|
||||||
|
import { summarizeMarkdown } from './content-condenser';
|
||||||
|
|
||||||
|
export interface CondensationResult {
|
||||||
|
context: string;
|
||||||
|
totalTokens: number;
|
||||||
|
originalTokens: number;
|
||||||
|
compressionRatio: number;
|
||||||
|
chunksUsed: number;
|
||||||
|
chunksTotal: number;
|
||||||
|
method: 'chunks' | 'summarized' | 'fallback';
|
||||||
|
sources: Array<{ title: string; url: string; chunksUsed: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CondensationOptions {
|
||||||
|
maxTokens?: number;
|
||||||
|
minRelevanceScore?: number;
|
||||||
|
autoSummarize?: boolean;
|
||||||
|
summarizeThreshold?: number;
|
||||||
|
fallbackToSummarization?: boolean;
|
||||||
|
context?: QueryContext; // Contexto analisado para ranqueamento semântico
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<CondensationOptions> = {
|
||||||
|
maxTokens: 12000,
|
||||||
|
minRelevanceScore: 0.1,
|
||||||
|
autoSummarize: true,
|
||||||
|
summarizeThreshold: 5000,
|
||||||
|
fallbackToSummarization: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estima tokens de um texto (aproximação: 1 token ≈ 4 caracteres)
|
||||||
|
*/
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sumariza todas as entradas e combina em um único contexto
|
||||||
|
*
|
||||||
|
* @param entries - Entradas da Knowledge Base
|
||||||
|
* @param query - Query do usuário
|
||||||
|
* @param maxSummaryLength - Comprimento máximo de cada sumário
|
||||||
|
* @returns Contexto sumarizado
|
||||||
|
*/
|
||||||
|
function fallbackSummarization(
|
||||||
|
entries: ResearchEntry[],
|
||||||
|
query: string,
|
||||||
|
maxSummaryLength: number = 1500
|
||||||
|
): string {
|
||||||
|
const summaries: string[] = [];
|
||||||
|
|
||||||
|
entries.forEach((entry, idx) => {
|
||||||
|
const summary = summarizeMarkdown(entry.content, maxSummaryLength);
|
||||||
|
summaries.push(
|
||||||
|
`[ID: ${idx + 1}] **${entry.title}**\nURL: ${entry.sourceUrl}\n\n${summary}\n\n---\n\n`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const combined = summaries.join('');
|
||||||
|
return `## KNOWLEDGE BASE (Resumo Condensado)\n\n${combined}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Condensa Knowledge Base usando chunking inteligente e scoring
|
||||||
|
*
|
||||||
|
* @param entries - Entradas da Knowledge Base
|
||||||
|
* @param query - Query do usuário
|
||||||
|
* @param options - Opções de condensação
|
||||||
|
* @returns Resultado da condensação
|
||||||
|
*/
|
||||||
|
export function condenseKnowledgeBase(
|
||||||
|
entries: ResearchEntry[],
|
||||||
|
query: string,
|
||||||
|
options: CondensationOptions = {}
|
||||||
|
): CondensationResult {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return {
|
||||||
|
context: '',
|
||||||
|
totalTokens: 0,
|
||||||
|
originalTokens: 0,
|
||||||
|
compressionRatio: 0,
|
||||||
|
chunksUsed: 0,
|
||||||
|
chunksTotal: 0,
|
||||||
|
method: 'fallback',
|
||||||
|
sources: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular tokens originais
|
||||||
|
const originalContent = entries.map(e => e.content).join('\n\n');
|
||||||
|
const originalTokens = estimateTokens(originalContent);
|
||||||
|
|
||||||
|
// Processar cada entrada
|
||||||
|
const allChunks: Chunk[] = [];
|
||||||
|
const processedEntries: Array<{
|
||||||
|
entry: ResearchEntry;
|
||||||
|
processed: ProcessedContent;
|
||||||
|
chunks: Chunk[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
entries.forEach((entry, entryIndex) => {
|
||||||
|
const processed = processContent(entry.content, query, {
|
||||||
|
summarizeThreshold: opts.summarizeThreshold,
|
||||||
|
autoSummarize: opts.autoSummarize,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atribuir sourceIndex e metadados aos chunks
|
||||||
|
const chunksWithSource = processed.chunks.map((chunk, chunkIdx) => ({
|
||||||
|
...chunk,
|
||||||
|
sourceIndex: entryIndex,
|
||||||
|
sourceUrl: entry.sourceUrl,
|
||||||
|
sourceTitle: entry.title,
|
||||||
|
}));
|
||||||
|
|
||||||
|
processedEntries.push({
|
||||||
|
entry,
|
||||||
|
processed,
|
||||||
|
chunks: chunksWithSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
allChunks.push(...chunksWithSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular tokens disponíveis (reservar 20% para margem)
|
||||||
|
const availableTokens = Math.floor(opts.maxTokens * 0.8);
|
||||||
|
|
||||||
|
// Selecionar chunks mais relevantes (usando contexto se disponível)
|
||||||
|
let selectedChunks = scoreAndSelectChunks(
|
||||||
|
allChunks,
|
||||||
|
query,
|
||||||
|
availableTokens,
|
||||||
|
opts.minRelevanceScore,
|
||||||
|
opts.context
|
||||||
|
);
|
||||||
|
|
||||||
|
// Se não selecionou chunks suficientes ou método de fallback está ativo
|
||||||
|
let method: 'chunks' | 'summarized' | 'fallback' = 'chunks';
|
||||||
|
let context = '';
|
||||||
|
|
||||||
|
if (selectedChunks.length === 0 && opts.fallbackToSummarization) {
|
||||||
|
// Fallback: sumarizar tudo
|
||||||
|
method = 'fallback';
|
||||||
|
context = fallbackSummarization(entries, query);
|
||||||
|
} else {
|
||||||
|
// Construir contexto a partir dos chunks selecionados
|
||||||
|
const chunksBySource = new Map<number, Chunk[]>();
|
||||||
|
|
||||||
|
selectedChunks.forEach(chunk => {
|
||||||
|
if (!chunksBySource.has(chunk.sourceIndex)) {
|
||||||
|
chunksBySource.set(chunk.sourceIndex, []);
|
||||||
|
}
|
||||||
|
chunksBySource.get(chunk.sourceIndex)!.push(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar chunks por fonte
|
||||||
|
const sourceBlocks: string[] = [];
|
||||||
|
const sourceStats: Array<{ title: string; url: string; chunksUsed: number }> = [];
|
||||||
|
|
||||||
|
chunksBySource.forEach((chunks, sourceIndex) => {
|
||||||
|
const entry = entries[sourceIndex];
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const sourceChunks = chunks
|
||||||
|
.sort((a, b) => a.startIndex - b.startIndex)
|
||||||
|
.map(c => c.content)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
sourceBlocks.push(
|
||||||
|
`[ID: ${sourceIndex + 1}] **${entry.title}**\nURL: ${entry.sourceUrl}\n\n${sourceChunks}\n\n---\n\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
sourceStats.push({
|
||||||
|
title: entry.title,
|
||||||
|
url: entry.sourceUrl,
|
||||||
|
chunksUsed: chunks.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
context = `## KNOWLEDGE BASE (Verified Sources)\n\n${sourceBlocks.join('')}`;
|
||||||
|
|
||||||
|
// Verificar se contexto excede limite
|
||||||
|
const contextTokens = estimateTokens(context);
|
||||||
|
if (contextTokens > opts.maxTokens && opts.fallbackToSummarization) {
|
||||||
|
// Se ainda exceder, usar sumarização
|
||||||
|
method = 'summarized';
|
||||||
|
context = fallbackSummarization(entries, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTokens = estimateTokens(context);
|
||||||
|
const compressionRatio = originalTokens > 0 ? totalTokens / originalTokens : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: context.trim(),
|
||||||
|
totalTokens,
|
||||||
|
originalTokens,
|
||||||
|
compressionRatio,
|
||||||
|
chunksUsed: selectedChunks.length,
|
||||||
|
chunksTotal: allChunks.length,
|
||||||
|
method,
|
||||||
|
sources: entries.map((entry, idx) => ({
|
||||||
|
title: entry.title,
|
||||||
|
url: entry.sourceUrl,
|
||||||
|
chunksUsed: selectedChunks.filter(c => c.sourceIndex === idx).length,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida se o contexto condensado contém informações relevantes
|
||||||
|
*
|
||||||
|
* @param result - Resultado da condensação
|
||||||
|
* @param query - Query do usuário
|
||||||
|
* @returns true se válido
|
||||||
|
*/
|
||||||
|
export function validateCondensedContext(
|
||||||
|
result: CondensationResult,
|
||||||
|
query: string
|
||||||
|
): { isValid: boolean; warnings: string[] } {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!result.context || result.context.length < 100) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
warnings: ['Contexto condensado está vazio ou muito curto'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.chunksUsed === 0 && result.method !== 'fallback') {
|
||||||
|
warnings.push('Nenhum chunk relevante foi selecionado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.compressionRatio > 0.5) {
|
||||||
|
warnings.push(`Alta taxa de compressão (${(result.compressionRatio * 100).toFixed(1)}%) - informações podem ter sido perdidas`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.chunksUsed < result.chunksTotal * 0.3) {
|
||||||
|
warnings.push(`Apenas ${result.chunksUsed} de ${result.chunksTotal} chunks foram selecionados`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se query aparece no contexto (básico)
|
||||||
|
const normalizedQuery = query.toLowerCase();
|
||||||
|
const normalizedContext = result.context.toLowerCase();
|
||||||
|
const queryWords = normalizedQuery.split(/\s+/).filter(w => w.length > 3);
|
||||||
|
const matchingWords = queryWords.filter(word => normalizedContext.includes(word));
|
||||||
|
|
||||||
|
if (matchingWords.length < queryWords.length * 0.5) {
|
||||||
|
warnings.push('Poucas palavras-chave da query aparecem no contexto');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: result.context.length >= 100,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const METADATA_BLOCK_REGEX = /<metadata>[\s\S]*?<\/metadata>/gi;
|
||||||
|
const METADATA_TAIL_REGEX = /(metadados?|metadata|hidden metadata block|bloco oculto de metadados|json metadata)\s*:?\s*/gi;
|
||||||
|
|
||||||
|
export function removeMetadataNoise(text: string): string {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleaned = text.replace(METADATA_BLOCK_REGEX, '').trim();
|
||||||
|
|
||||||
|
// Remove any trailing sections that start with common metadata prefixes
|
||||||
|
cleaned = cleaned.replace(
|
||||||
|
/(metadados?|metadata|hidden metadata block|bloco oculto de metadados|json metadata)\s*:?\s*[\s\S]*$/gi,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove standalone prefixes that might appear inline
|
||||||
|
cleaned = cleaned.replace(METADATA_TAIL_REGEX, '');
|
||||||
|
|
||||||
|
return cleaned.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import type { Message } from '@/hooks/use-chat';
|
||||||
|
import { useSettingsStore } from '@/store/settings-store';
|
||||||
|
|
||||||
|
export interface ModelContextInfo {
|
||||||
|
modelName: string;
|
||||||
|
maxContextWindow: number;
|
||||||
|
recommendedContextWindow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estima tokens de um texto (aproximação: 1 token ≈ 4 caracteres)
|
||||||
|
*/
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta o context window máximo do modelo consultando Ollama API
|
||||||
|
*
|
||||||
|
* @param modelName - Nome do modelo
|
||||||
|
* @returns Limite máximo de tokens do modelo
|
||||||
|
*/
|
||||||
|
export async function detectModelContextWindow(modelName: string): Promise<number> {
|
||||||
|
if (!modelName) {
|
||||||
|
// Fallback para settings
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
return settings.contextWindow || 4096;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Tentar obter informações do modelo via Ollama API
|
||||||
|
const response = await fetch('http://localhost:11434/api/show', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: modelName,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Ollama retorna 'context_length' ou 'parameter_size' que pode indicar contexto
|
||||||
|
// Mapear tamanhos conhecidos de modelos
|
||||||
|
const modelInfo = data.modelfile || '';
|
||||||
|
const contextMatch = modelInfo.match(/context[_\s]*length[:\s]*(\d+)/i);
|
||||||
|
|
||||||
|
if (contextMatch) {
|
||||||
|
return parseInt(contextMatch[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar inferir do tamanho do modelo
|
||||||
|
const size = data.size || '';
|
||||||
|
if (size.includes('7b') || size.includes('8b')) {
|
||||||
|
return 8192; // Modelos 7B-8B geralmente têm 8k
|
||||||
|
}
|
||||||
|
if (size.includes('13b') || size.includes('14b')) {
|
||||||
|
return 16384; // Modelos 13B-14B geralmente têm 16k
|
||||||
|
}
|
||||||
|
if (size.includes('32k') || size.includes('128k')) {
|
||||||
|
return 32768; // Modelos com contexto estendido
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao detectar context window do modelo:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: usar contextWindow das settings
|
||||||
|
const settings = useSettingsStore.getState();
|
||||||
|
return settings.contextWindow || 4096;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula tokens disponíveis para Knowledge Base
|
||||||
|
*
|
||||||
|
* @param systemPrompt - System prompt
|
||||||
|
* @param messages - Mensagens do histórico
|
||||||
|
* @param modelMaxTokens - Limite máximo de tokens do modelo
|
||||||
|
* @returns Tokens disponíveis para Knowledge Base
|
||||||
|
*/
|
||||||
|
export function calculateAvailableTokens(
|
||||||
|
systemPrompt: string,
|
||||||
|
messages: Message[],
|
||||||
|
modelMaxTokens: number
|
||||||
|
): number {
|
||||||
|
// Calcular tokens usados
|
||||||
|
let usedTokens = 0;
|
||||||
|
|
||||||
|
// System prompt
|
||||||
|
if (systemPrompt) {
|
||||||
|
usedTokens += estimateTokens(systemPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mensagens do histórico
|
||||||
|
messages.forEach(msg => {
|
||||||
|
usedTokens += estimateTokens(msg.content);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reservar 10% para margem de segurança e resposta da IA
|
||||||
|
const reservedTokens = Math.ceil(modelMaxTokens * 0.1);
|
||||||
|
const availableTokens = modelMaxTokens - usedTokens - reservedTokens;
|
||||||
|
|
||||||
|
// Garantir mínimo de 1000 tokens disponíveis
|
||||||
|
return Math.max(availableTokens, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém informações completas do contexto do modelo
|
||||||
|
*
|
||||||
|
* @param modelName - Nome do modelo
|
||||||
|
* @param systemPrompt - System prompt atual
|
||||||
|
* @param messages - Mensagens do histórico
|
||||||
|
* @returns Informações do contexto
|
||||||
|
*/
|
||||||
|
export async function getModelContextInfo(
|
||||||
|
modelName: string,
|
||||||
|
systemPrompt: string = '',
|
||||||
|
messages: Message[] = []
|
||||||
|
): Promise<ModelContextInfo> {
|
||||||
|
const maxContextWindow = await detectModelContextWindow(modelName);
|
||||||
|
const availableTokens = calculateAvailableTokens(systemPrompt, messages, maxContextWindow);
|
||||||
|
|
||||||
|
// Recommended é 80% do disponível para garantir margem
|
||||||
|
const recommendedContextWindow = Math.floor(availableTokens * 0.8);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelName,
|
||||||
|
maxContextWindow,
|
||||||
|
recommendedContextWindow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import type { QueryContext } from './contextual-analyzer';
|
||||||
|
import { chatLog } from './terminal-logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries enriquecidas por categoria
|
||||||
|
*/
|
||||||
|
export interface EnrichedQueries {
|
||||||
|
literal: string[]; // Queries literais (original + variações diretas)
|
||||||
|
semantic: string[]; // Variantes semânticas (sinônimos, reformulações)
|
||||||
|
related: string[]; // Perguntas relacionadas
|
||||||
|
expanded: string[]; // Expansões temáticas (autores, eventos, tipos)
|
||||||
|
contextual: string[]; // Queries com contexto adicional (datas, locais)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt para enriquecimento de queries usando LLM
|
||||||
|
*/
|
||||||
|
const QUERY_ENRICHMENT_PROMPT = `Você é um especialista em otimização de queries de busca e expansão semântica.
|
||||||
|
|
||||||
|
Com base na query original e no contexto analisado, gere múltiplas variantes de queries enriquecidas para maximizar a cobertura de resultados relevantes.
|
||||||
|
|
||||||
|
## QUERY ORIGINAL
|
||||||
|
"{{originalQuery}}"
|
||||||
|
|
||||||
|
## CONTEXTO ANALISADO
|
||||||
|
- Intenção: {{intent}}
|
||||||
|
- Tópicos: {{topics}}
|
||||||
|
- Entidades: {{entities}}
|
||||||
|
- Contexto Temporal: {{temporalContext}}
|
||||||
|
- Contexto Geográfico: {{geographicContext}}
|
||||||
|
- Palavras-chave: {{keywords}}
|
||||||
|
- Sinônimos: {{synonyms}}
|
||||||
|
- Domínio: {{domain}}
|
||||||
|
|
||||||
|
## INSTRUÇÕES
|
||||||
|
|
||||||
|
Gere queries enriquecidas nas seguintes categorias:
|
||||||
|
|
||||||
|
1. **LITERAL**: Variações diretas da query original (preservar termos principais)
|
||||||
|
- Ex: "resultados acadêmicos premiados" → ["resultados academicos premiados", "ultimos resultados academicos premiados"]
|
||||||
|
|
||||||
|
2. **SEMANTIC**: Variantes semânticas usando sinônimos e reformulações
|
||||||
|
- Ex: "resultados acadêmicos" → "pesquisas científicas", "estudos acadêmicos", "trabalhos científicos"
|
||||||
|
- Manter o significado, mas usar termos alternativos
|
||||||
|
|
||||||
|
3. **RELATED**: Perguntas relacionadas e FAQs
|
||||||
|
- Ex: "premios academicos 2024", "conferencias academicas premiadas", "descobertas cientificas premiadas"
|
||||||
|
- Explorar aspectos adjacentes do tópico
|
||||||
|
|
||||||
|
4. **EXPANDED**: Expansões temáticas específicas
|
||||||
|
- Ex: "Nobel Prize 2024", "Turing Award winners", "Fields Medal recentes"
|
||||||
|
- Incluir tipos específicos, categorias, exemplos concretos
|
||||||
|
|
||||||
|
5. **CONTEXTUAL**: Queries com contexto adicional (datas, locais, domínios)
|
||||||
|
- Ex: "resultados academicos brasileiros premiados 2024", "pesquisas premiadas universidades"
|
||||||
|
- Combinar tópico principal com contexto temporal/geográfico
|
||||||
|
|
||||||
|
## REGRAS CRÍTICAS
|
||||||
|
|
||||||
|
- **PRESERVAR ENTIDADES**: Se a query menciona "Garuva", TODAS as queries devem mencionar "Garuva"
|
||||||
|
- **NÃO TRADUZIR**: Preservar nomes próprios, termos técnicos na forma original
|
||||||
|
- **OTIMIZAR PARA BUSCA**: Usar termos que aparecem em resultados reais
|
||||||
|
- **COBERTURA AMPLA**: Cada categoria deve explorar diferentes ângulos
|
||||||
|
- **QUALIDADE > QUANTIDADE**: 3-5 queries por categoria é suficiente
|
||||||
|
- **RELEVÂNCIA**: Todas as queries devem ser relevantes para responder a query original
|
||||||
|
|
||||||
|
## FORMATO DE SAÍDA
|
||||||
|
|
||||||
|
Retorne APENAS um JSON válido no seguinte formato:
|
||||||
|
|
||||||
|
{
|
||||||
|
"literal": ["query literal 1", "query literal 2"],
|
||||||
|
"semantic": ["variante semantica 1", "variante semantica 2"],
|
||||||
|
"related": ["pergunta relacionada 1", "pergunta relacionada 2"],
|
||||||
|
"expanded": ["expansao tematica 1", "expansao tematica 2"],
|
||||||
|
"contextual": ["query contextual 1", "query contextual 2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPORTANTE:
|
||||||
|
- Retorne APENAS o JSON, sem markdown, sem explicações
|
||||||
|
- Cada array deve ter 3-5 queries
|
||||||
|
- Queries devem ser em português se a query original for em português
|
||||||
|
- Preservar termos técnicos e nomes próprios`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enriquece queries baseado no contexto analisado
|
||||||
|
*
|
||||||
|
* @param originalQuery - Query original do usuário
|
||||||
|
* @param context - Contexto analisado da query
|
||||||
|
* @param model - Modelo LLM a ser usado
|
||||||
|
* @returns Queries enriquecidas por categoria
|
||||||
|
*/
|
||||||
|
export async function enrichQueries(
|
||||||
|
originalQuery: string,
|
||||||
|
context: QueryContext,
|
||||||
|
model: string
|
||||||
|
): Promise<EnrichedQueries> {
|
||||||
|
chatLog.info(`[QueryEnricher] Enriquecendo queries para: "${originalQuery}"`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Preparar contexto para o prompt
|
||||||
|
const entitiesStr = context.entities.map(e => `${e.type}: ${e.value}`).join(', ') || 'nenhuma';
|
||||||
|
const temporalStr = context.temporalContext
|
||||||
|
? JSON.stringify(context.temporalContext)
|
||||||
|
: 'nenhum';
|
||||||
|
const geographicStr = context.geographicContext
|
||||||
|
? JSON.stringify(context.geographicContext)
|
||||||
|
: 'nenhum';
|
||||||
|
|
||||||
|
const prompt = QUERY_ENRICHMENT_PROMPT
|
||||||
|
.replace('{{originalQuery}}', originalQuery)
|
||||||
|
.replace('{{intent}}', context.intent)
|
||||||
|
.replace('{{topics}}', context.topics.join(', ') || 'nenhum')
|
||||||
|
.replace('{{entities}}', entitiesStr)
|
||||||
|
.replace('{{temporalContext}}', temporalStr)
|
||||||
|
.replace('{{geographicContext}}', geographicStr)
|
||||||
|
.replace('{{keywords}}', context.keywords.join(', ') || 'nenhuma')
|
||||||
|
.replace('{{synonyms}}', context.synonyms.join(', ') || 'nenhum')
|
||||||
|
.replace('{{domain}}', context.domain || 'geral');
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:11434/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
options: {
|
||||||
|
temperature: 0.5, // Temperatura média para criatividade controlada
|
||||||
|
num_predict: 1000, // Espaço suficiente para múltiplas queries
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const rawResponse = (data.response || '').trim();
|
||||||
|
|
||||||
|
// Limpar resposta (remover markdown code blocks se houver)
|
||||||
|
let jsonText = rawResponse
|
||||||
|
.replace(/^```json\s*/i, '')
|
||||||
|
.replace(/^```\s*/i, '')
|
||||||
|
.replace(/```\s*$/i, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Tentar extrair JSON se houver texto antes/depois
|
||||||
|
const jsonMatch = jsonText.match(/\{[\s\S]*\}/);
|
||||||
|
if (jsonMatch) {
|
||||||
|
jsonText = jsonMatch[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched = JSON.parse(jsonText) as Partial<EnrichedQueries>;
|
||||||
|
|
||||||
|
// Validar e preencher campos
|
||||||
|
const result: EnrichedQueries = {
|
||||||
|
literal: enriched.literal || [originalQuery],
|
||||||
|
semantic: enriched.semantic || [],
|
||||||
|
related: enriched.related || [],
|
||||||
|
expanded: enriched.expanded || [],
|
||||||
|
contextual: enriched.contextual || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Garantir que sempre há pelo menos a query original
|
||||||
|
if (result.literal.length === 0) {
|
||||||
|
result.literal = [originalQuery];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log estatísticas
|
||||||
|
const totalQueries =
|
||||||
|
result.literal.length +
|
||||||
|
result.semantic.length +
|
||||||
|
result.related.length +
|
||||||
|
result.expanded.length +
|
||||||
|
result.contextual.length;
|
||||||
|
|
||||||
|
chatLog.success(
|
||||||
|
`[QueryEnricher] ${totalQueries} queries geradas: ` +
|
||||||
|
`${result.literal.length} literal, ${result.semantic.length} semântica, ` +
|
||||||
|
`${result.related.length} relacionadas, ${result.expanded.length} expandidas, ` +
|
||||||
|
`${result.contextual.length} contextuais`
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
chatLog.error(`[QueryEnricher] Erro ao enriquecer queries:`, error);
|
||||||
|
|
||||||
|
// Fallback: usar apenas query original
|
||||||
|
return createFallbackEnrichment(originalQuery, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cria enriquecimento básico usando heurísticas quando a análise LLM falha
|
||||||
|
*/
|
||||||
|
function createFallbackEnrichment(
|
||||||
|
originalQuery: string,
|
||||||
|
context: QueryContext
|
||||||
|
): EnrichedQueries {
|
||||||
|
const literal = [originalQuery];
|
||||||
|
|
||||||
|
// Adicionar variações básicas usando sinônimos do contexto
|
||||||
|
const semantic: string[] = [];
|
||||||
|
if (context.synonyms.length > 0) {
|
||||||
|
// Substituir algumas palavras por sinônimos
|
||||||
|
let semanticQuery = originalQuery;
|
||||||
|
context.synonyms.slice(0, 2).forEach(synonym => {
|
||||||
|
// Tentar substituir palavra-chave por sinônimo
|
||||||
|
const keywords = context.keywords;
|
||||||
|
if (keywords.length > 0) {
|
||||||
|
const keyword = keywords[0];
|
||||||
|
semanticQuery = semanticQuery.replace(
|
||||||
|
new RegExp(keyword, 'gi'),
|
||||||
|
synonym
|
||||||
|
);
|
||||||
|
if (semanticQuery !== originalQuery) {
|
||||||
|
semantic.push(semanticQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar contexto temporal se disponível
|
||||||
|
const contextual: string[] = [];
|
||||||
|
if (context.temporalContext?.period) {
|
||||||
|
contextual.push(`${originalQuery} ${context.temporalContext.period}`);
|
||||||
|
}
|
||||||
|
if (context.temporalContext?.relative === 'recente') {
|
||||||
|
contextual.push(`${originalQuery} recente`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adicionar contexto geográfico se disponível
|
||||||
|
if (context.geographicContext?.location) {
|
||||||
|
contextual.push(`${originalQuery} ${context.geographicContext.location}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
literal,
|
||||||
|
semantic: semantic.length > 0 ? semantic : [],
|
||||||
|
related: [],
|
||||||
|
expanded: [],
|
||||||
|
contextual,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combina todas as queries enriquecidas em uma lista única para busca
|
||||||
|
*
|
||||||
|
* @param enriched - Queries enriquecidas
|
||||||
|
* @param strategy - Estratégia de combinação
|
||||||
|
* @returns Lista de queries para buscar
|
||||||
|
*/
|
||||||
|
export function combineEnrichedQueries(
|
||||||
|
enriched: EnrichedQueries,
|
||||||
|
strategy: 'all' | 'prioritize-literal' | 'prioritize-semantic' = 'prioritize-literal'
|
||||||
|
): string[] {
|
||||||
|
const queries: string[] = [];
|
||||||
|
|
||||||
|
switch (strategy) {
|
||||||
|
case 'all':
|
||||||
|
queries.push(...enriched.literal);
|
||||||
|
queries.push(...enriched.semantic);
|
||||||
|
queries.push(...enriched.related);
|
||||||
|
queries.push(...enriched.expanded);
|
||||||
|
queries.push(...enriched.contextual);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'prioritize-literal':
|
||||||
|
queries.push(...enriched.literal);
|
||||||
|
queries.push(...enriched.semantic);
|
||||||
|
queries.push(...enriched.contextual);
|
||||||
|
queries.push(...enriched.related);
|
||||||
|
queries.push(...enriched.expanded);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'prioritize-semantic':
|
||||||
|
queries.push(...enriched.semantic);
|
||||||
|
queries.push(...enriched.literal);
|
||||||
|
queries.push(...enriched.contextual);
|
||||||
|
queries.push(...enriched.related);
|
||||||
|
queries.push(...enriched.expanded);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover duplicatas mantendo ordem
|
||||||
|
const unique = Array.from(new Set(queries.map(q => q.toLowerCase())))
|
||||||
|
.map(lower => queries.find(q => q.toLowerCase() === lower)!)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import emojiRegex from 'emoji-regex';
|
||||||
|
import { normalizeText } from './text-normalization';
|
||||||
|
|
||||||
|
export interface ValidationOptions {
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
irrelevantPatterns?: string[];
|
||||||
|
allowEmojis?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
normalizedLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<ValidationOptions> = {
|
||||||
|
minLength: 3,
|
||||||
|
maxLength: 2000,
|
||||||
|
irrelevantPatterns: [
|
||||||
|
'oi',
|
||||||
|
'olá',
|
||||||
|
'ola',
|
||||||
|
'teste',
|
||||||
|
'test',
|
||||||
|
'hello',
|
||||||
|
'hi',
|
||||||
|
'hey',
|
||||||
|
'ok',
|
||||||
|
'okay',
|
||||||
|
'sim',
|
||||||
|
'não',
|
||||||
|
'nao',
|
||||||
|
'yes',
|
||||||
|
'no',
|
||||||
|
],
|
||||||
|
allowEmojis: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida uma query do usuário
|
||||||
|
*
|
||||||
|
* @param text - Texto a ser validado
|
||||||
|
* @param options - Opções de validação
|
||||||
|
* @returns Resultado da validação
|
||||||
|
*/
|
||||||
|
export function validateQuery(
|
||||||
|
text: string,
|
||||||
|
options: ValidationOptions = {}
|
||||||
|
): ValidationResult {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let normalizedLength = 0;
|
||||||
|
|
||||||
|
// Verificar se o texto existe e é string
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
errors: ['Texto vazio ou inválido'],
|
||||||
|
warnings: [],
|
||||||
|
normalizedLength: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizar texto para validação
|
||||||
|
const normalized = normalizeText(text);
|
||||||
|
normalizedLength = normalized.length;
|
||||||
|
|
||||||
|
// Verificar comprimento mínimo
|
||||||
|
if (normalizedLength < opts.minLength) {
|
||||||
|
errors.push(
|
||||||
|
`Texto muito curto. Mínimo de ${opts.minLength} caracteres necessário.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar comprimento máximo
|
||||||
|
if (normalizedLength > opts.maxLength) {
|
||||||
|
errors.push(
|
||||||
|
`Texto muito longo. Máximo de ${opts.maxLength} caracteres permitido.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é apenas espaços em branco após normalização
|
||||||
|
if (normalized.trim().length === 0) {
|
||||||
|
errors.push('Texto contém apenas espaços em branco ou caracteres especiais.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se contém apenas emojis
|
||||||
|
if (!opts.allowEmojis) {
|
||||||
|
const emojiPattern = emojiRegex();
|
||||||
|
const textWithoutEmojis = text.replace(emojiPattern, '').trim();
|
||||||
|
if (textWithoutEmojis.length === 0 && text.trim().length > 0) {
|
||||||
|
errors.push('Texto contém apenas emojis.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se contém apenas pontuação
|
||||||
|
const textWithoutPunctuation = normalized.replace(/[.,?!:;]/g, '').trim();
|
||||||
|
if (textWithoutPunctuation.length === 0 && normalized.length > 0) {
|
||||||
|
errors.push('Texto contém apenas pontuação.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar padrões irrelevantes
|
||||||
|
if (isIrrelevantQuery(normalized, opts.irrelevantPatterns)) {
|
||||||
|
warnings.push(
|
||||||
|
'Pergunta pode ser irrelevante ou muito simples. Considere ser mais específico.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
normalizedLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se uma query é irrelevante baseado em padrões
|
||||||
|
*
|
||||||
|
* @param text - Texto normalizado a ser verificado
|
||||||
|
* @param patterns - Lista de padrões irrelevantes
|
||||||
|
* @returns true se a query for irrelevante
|
||||||
|
*/
|
||||||
|
export function isIrrelevantQuery(
|
||||||
|
text: string,
|
||||||
|
patterns: string[] = DEFAULT_OPTIONS.irrelevantPatterns
|
||||||
|
): boolean {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeText(text);
|
||||||
|
const normalizedPatterns = patterns.map((p) => normalizeText(p));
|
||||||
|
|
||||||
|
// Verificar se o texto normalizado está exatamente na lista de padrões
|
||||||
|
if (normalizedPatterns.includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o texto começa com um padrão irrelevante seguido apenas de pontuação/espaços
|
||||||
|
for (const pattern of normalizedPatterns) {
|
||||||
|
const regex = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s.,?!]*$`, 'i');
|
||||||
|
if (regex.test(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validação rápida (apenas comprimento)
|
||||||
|
* Útil para validação em tempo real no input
|
||||||
|
*
|
||||||
|
* @param text - Texto a ser validado
|
||||||
|
* @param minLength - Comprimento mínimo
|
||||||
|
* @param maxLength - Comprimento máximo
|
||||||
|
* @returns true se válido
|
||||||
|
*/
|
||||||
|
export function quickValidate(
|
||||||
|
text: string,
|
||||||
|
minLength: number = DEFAULT_OPTIONS.minLength,
|
||||||
|
maxLength: number = DEFAULT_OPTIONS.maxLength
|
||||||
|
): boolean {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeText(text);
|
||||||
|
return (
|
||||||
|
normalized.length >= minLength && normalized.length <= maxLength
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { normalizeText } from './text-normalization';
|
||||||
|
|
||||||
|
export interface SplitResult {
|
||||||
|
questions: string[];
|
||||||
|
originalText: string;
|
||||||
|
splitCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Palavras interrogativas em português e inglês
|
||||||
|
const INTERROGATIVE_WORDS = [
|
||||||
|
'o que',
|
||||||
|
'que',
|
||||||
|
'qual',
|
||||||
|
'quais',
|
||||||
|
'quem',
|
||||||
|
'onde',
|
||||||
|
'quando',
|
||||||
|
'como',
|
||||||
|
'por que',
|
||||||
|
'porque',
|
||||||
|
'por quê',
|
||||||
|
'porquê',
|
||||||
|
'quantos',
|
||||||
|
'quantas',
|
||||||
|
'what',
|
||||||
|
'which',
|
||||||
|
'who',
|
||||||
|
'where',
|
||||||
|
'when',
|
||||||
|
'how',
|
||||||
|
'why',
|
||||||
|
'how many',
|
||||||
|
'how much',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Conectores que podem separar perguntas
|
||||||
|
const QUESTION_SEPARATORS = [
|
||||||
|
/\s+e\s+/gi, // " e "
|
||||||
|
/\s+ou\s+/gi, // " ou "
|
||||||
|
/[.!?]\s+/g, // ". ", "! ", "? "
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se um texto é uma pergunta
|
||||||
|
*
|
||||||
|
* @param text - Texto a ser verificado
|
||||||
|
* @returns true se for uma pergunta
|
||||||
|
*/
|
||||||
|
export function isQuestion(text: string): boolean {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se termina com ponto de interrogação
|
||||||
|
if (trimmed.endsWith('?')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica se começa com palavra interrogativa
|
||||||
|
const normalized = normalizeText(trimmed);
|
||||||
|
for (const word of INTERROGATIVE_WORDS) {
|
||||||
|
const normalizedWord = normalizeText(word);
|
||||||
|
if (normalized.startsWith(normalizedWord + ' ')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifica padrões de pergunta
|
||||||
|
const questionPatterns = [
|
||||||
|
/^(o que|que|qual|quais|quem|onde|quando|como|por que|porque|quantos|quantas)/i,
|
||||||
|
/^(what|which|who|where|when|how|why|how many|how much)/i,
|
||||||
|
/\?$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of questionPatterns) {
|
||||||
|
if (pattern.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula similaridade básica entre duas strings (Jaccard similarity simplificada)
|
||||||
|
*
|
||||||
|
* @param str1 - Primeira string
|
||||||
|
* @param str2 - Segunda string
|
||||||
|
* @returns Similaridade entre 0 e 1
|
||||||
|
*/
|
||||||
|
function calculateSimilarity(str1: string, str2: string): number {
|
||||||
|
const words1 = new Set(normalizeText(str1).split(/\s+/).filter((w) => w.length > 2));
|
||||||
|
const words2 = new Set(normalizeText(str2).split(/\s+/).filter((w) => w.length > 2));
|
||||||
|
|
||||||
|
if (words1.size === 0 && words2.size === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (words1.size === 0 || words2.size === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intersection = new Set([...words1].filter((w) => words2.has(w)));
|
||||||
|
const union = new Set([...words1, ...words2]);
|
||||||
|
|
||||||
|
return intersection.size / union.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove perguntas duplicadas ou muito similares
|
||||||
|
*
|
||||||
|
* @param questions - Array de perguntas
|
||||||
|
* @param similarityThreshold - Limiar de similaridade (0-1)
|
||||||
|
* @returns Array de perguntas únicas
|
||||||
|
*/
|
||||||
|
function removeDuplicates(
|
||||||
|
questions: string[],
|
||||||
|
similarityThreshold: number = 0.7
|
||||||
|
): string[] {
|
||||||
|
const unique: string[] = [];
|
||||||
|
|
||||||
|
for (const question of questions) {
|
||||||
|
let isDuplicate = false;
|
||||||
|
|
||||||
|
for (const existing of unique) {
|
||||||
|
const similarity = calculateSimilarity(question, existing);
|
||||||
|
if (similarity >= similarityThreshold) {
|
||||||
|
isDuplicate = true;
|
||||||
|
// Manter a versão mais longa (geralmente mais completa)
|
||||||
|
if (question.length > existing.length) {
|
||||||
|
const index = unique.indexOf(existing);
|
||||||
|
unique[index] = question;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDuplicate) {
|
||||||
|
unique.push(question);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Divide um texto em múltiplas perguntas
|
||||||
|
*
|
||||||
|
* @param text - Texto a ser dividido
|
||||||
|
* @returns Resultado com array de perguntas
|
||||||
|
*/
|
||||||
|
export function splitQuestions(text: string): SplitResult {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return {
|
||||||
|
questions: [],
|
||||||
|
originalText: text || '',
|
||||||
|
splitCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalText = text.trim();
|
||||||
|
if (originalText.length === 0) {
|
||||||
|
return {
|
||||||
|
questions: [],
|
||||||
|
originalText: '',
|
||||||
|
splitCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar dividir por separadores de sentenças primeiro
|
||||||
|
let parts: string[] = [originalText];
|
||||||
|
|
||||||
|
// Dividir por pontuação de fim de sentença
|
||||||
|
const sentenceSplit = originalText.split(/[.!?]\s+/);
|
||||||
|
if (sentenceSplit.length > 1) {
|
||||||
|
parts = sentenceSplit.map((p) => p.trim()).filter((p) => p.length > 0);
|
||||||
|
} else {
|
||||||
|
// Tentar dividir por conectores
|
||||||
|
for (const separator of QUESTION_SEPARATORS) {
|
||||||
|
const split = originalText.split(separator);
|
||||||
|
if (split.length > 1) {
|
||||||
|
parts = split.map((p) => p.trim()).filter((p) => p.length > 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrar apenas partes que são perguntas
|
||||||
|
const questions = parts.filter((part) => {
|
||||||
|
// Adicionar "?" se não tiver mas for pergunta
|
||||||
|
const withQuestionMark = part.endsWith('?') ? part : part + '?';
|
||||||
|
return isQuestion(withQuestionMark);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se nenhuma parte foi identificada como pergunta, tratar o texto inteiro como uma pergunta
|
||||||
|
if (questions.length === 0) {
|
||||||
|
return {
|
||||||
|
questions: [originalText],
|
||||||
|
originalText,
|
||||||
|
splitCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover duplicatas
|
||||||
|
const uniqueQuestions = removeDuplicates(questions);
|
||||||
|
|
||||||
|
return {
|
||||||
|
questions: uniqueQuestions,
|
||||||
|
originalText,
|
||||||
|
splitCount: uniqueQuestions.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
|
export interface GpuInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
vendor: string | null;
|
||||||
|
memory_mb: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemSpecs {
|
export interface SystemSpecs {
|
||||||
total_memory: number; // Bytes
|
total_memory: number; // Bytes
|
||||||
cpu_count: number;
|
cpu_count: number;
|
||||||
os_name: string;
|
os_name: string;
|
||||||
|
gpus: GpuInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelRecommendation {
|
export interface ModelRecommendation {
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import type { Chunk } from './content-condenser';
|
||||||
|
import type { QueryContext } from './contextual-analyzer';
|
||||||
|
import { normalizeText } from './text-normalization';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula similaridade simples entre query e chunk baseado em palavras-chave
|
||||||
|
* Implementação simplificada de TF-IDF
|
||||||
|
*
|
||||||
|
* @param query - Query do usuário
|
||||||
|
* @param chunk - Chunk de conteúdo
|
||||||
|
* @returns Score de similaridade (0-1)
|
||||||
|
*/
|
||||||
|
export function calculateSimilarity(query: string, chunk: string): number {
|
||||||
|
if (!query || !chunk) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizar textos
|
||||||
|
const normalizedQuery = normalizeText(query);
|
||||||
|
const normalizedChunk = normalizeText(chunk);
|
||||||
|
|
||||||
|
// Extrair palavras-chave da query (remover stop words básicas)
|
||||||
|
const stopWords = new Set([
|
||||||
|
'o', 'a', 'os', 'as', 'um', 'uma', 'de', 'da', 'do', 'das', 'dos',
|
||||||
|
'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'com', 'sem',
|
||||||
|
'que', 'qual', 'quais', 'onde', 'quando', 'como', 'porque',
|
||||||
|
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
||||||
|
'of', 'with', 'by', 'from', 'as', 'is', 'are', 'was', 'were',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const queryWords = normalizedQuery
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(word => word.length > 2 && !stopWords.has(word));
|
||||||
|
|
||||||
|
if (queryWords.length === 0) {
|
||||||
|
return 0.1; // Score mínimo se não houver palavras-chave
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contar ocorrências de cada palavra-chave no chunk
|
||||||
|
let totalMatches = 0;
|
||||||
|
let uniqueMatches = 0;
|
||||||
|
const matchedWords = new Set<string>();
|
||||||
|
|
||||||
|
for (const word of queryWords) {
|
||||||
|
// Buscar palavra completa (word boundary)
|
||||||
|
const wordRegex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'gi');
|
||||||
|
const matches = normalizedChunk.match(wordRegex);
|
||||||
|
|
||||||
|
if (matches) {
|
||||||
|
totalMatches += matches.length;
|
||||||
|
if (!matchedWords.has(word)) {
|
||||||
|
matchedWords.add(word);
|
||||||
|
uniqueMatches++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular score baseado em:
|
||||||
|
// 1. Proporção de palavras-chave encontradas (peso: 0.6)
|
||||||
|
// 2. Frequência de matches (peso: 0.3)
|
||||||
|
// 3. Normalização por tamanho do chunk (peso: 0.1)
|
||||||
|
const wordCoverage = uniqueMatches / queryWords.length;
|
||||||
|
const frequencyScore = Math.min(totalMatches / queryWords.length, 2) / 2; // Cap em 2x
|
||||||
|
const sizeNormalization = Math.min(1, 1000 / normalizedChunk.length); // Penalizar chunks muito longos
|
||||||
|
|
||||||
|
const score = (
|
||||||
|
wordCoverage * 0.6 +
|
||||||
|
frequencyScore * 0.3 +
|
||||||
|
sizeNormalization * 0.1
|
||||||
|
);
|
||||||
|
|
||||||
|
// Garantir que score esteja entre 0 e 1
|
||||||
|
return Math.min(Math.max(score, 0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula similaridade semântica avançada usando contexto completo
|
||||||
|
* Considera entidades, tópicos, relacionamentos e intenção
|
||||||
|
*
|
||||||
|
* @param context - Contexto analisado da query
|
||||||
|
* @param chunk - Chunk de conteúdo
|
||||||
|
* @returns Score de similaridade semântica (0-1)
|
||||||
|
*/
|
||||||
|
export function calculateSemanticSimilarity(
|
||||||
|
context: QueryContext,
|
||||||
|
chunk: string
|
||||||
|
): number {
|
||||||
|
if (!chunk || !context) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedChunk = normalizeText(chunk).toLowerCase();
|
||||||
|
let totalScore = 0;
|
||||||
|
let weightSum = 0;
|
||||||
|
|
||||||
|
// 1. Match de keywords (peso: 0.3)
|
||||||
|
if (context.keywords.length > 0) {
|
||||||
|
let keywordMatches = 0;
|
||||||
|
for (const keyword of context.keywords) {
|
||||||
|
const keywordLower = keyword.toLowerCase();
|
||||||
|
if (normalizedChunk.includes(keywordLower)) {
|
||||||
|
keywordMatches++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const keywordScore = keywordMatches / context.keywords.length;
|
||||||
|
totalScore += keywordScore * 0.3;
|
||||||
|
weightSum += 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Match de entidades (peso: 0.3)
|
||||||
|
if (context.entities.length > 0) {
|
||||||
|
let entityMatches = 0;
|
||||||
|
for (const entity of context.entities) {
|
||||||
|
const entityLower = entity.value.toLowerCase();
|
||||||
|
// Buscar entidade completa (case-insensitive)
|
||||||
|
if (normalizedChunk.includes(entityLower)) {
|
||||||
|
entityMatches += entity.confidence; // Ponderar por confiança
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const entityScore = entityMatches / context.entities.length;
|
||||||
|
totalScore += entityScore * 0.3;
|
||||||
|
weightSum += 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Match de tópicos (peso: 0.2)
|
||||||
|
if (context.topics.length > 0) {
|
||||||
|
let topicMatches = 0;
|
||||||
|
for (const topic of context.topics) {
|
||||||
|
const topicLower = topic.toLowerCase();
|
||||||
|
// Verificar se tópico aparece no chunk (pode ser parcial)
|
||||||
|
const topicWords = topicLower.split(/\s+/);
|
||||||
|
const matches = topicWords.filter(word =>
|
||||||
|
word.length > 3 && normalizedChunk.includes(word)
|
||||||
|
);
|
||||||
|
if (matches.length >= topicWords.length * 0.5) { // 50% das palavras do tópico
|
||||||
|
topicMatches++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topicScore = context.topics.length > 0
|
||||||
|
? topicMatches / context.topics.length
|
||||||
|
: 0;
|
||||||
|
totalScore += topicScore * 0.2;
|
||||||
|
weightSum += 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Match de sinônimos (peso: 0.1)
|
||||||
|
if (context.synonyms.length > 0) {
|
||||||
|
let synonymMatches = 0;
|
||||||
|
for (const synonym of context.synonyms) {
|
||||||
|
const synonymLower = synonym.toLowerCase();
|
||||||
|
if (normalizedChunk.includes(synonymLower)) {
|
||||||
|
synonymMatches++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const synonymScore = context.synonyms.length > 0
|
||||||
|
? Math.min(synonymMatches / context.synonyms.length, 0.5) // Cap em 0.5 para não dominar
|
||||||
|
: 0;
|
||||||
|
totalScore += synonymScore * 0.1;
|
||||||
|
weightSum += 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Relevância contextual (peso: 0.1)
|
||||||
|
// Verificar se contexto temporal/geográfico aparece no chunk
|
||||||
|
let contextualScore = 0;
|
||||||
|
if (context.temporalContext?.period) {
|
||||||
|
if (normalizedChunk.includes(context.temporalContext.period)) {
|
||||||
|
contextualScore += 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (context.temporalContext?.relative) {
|
||||||
|
const relativeTerms = ['recente', 'atual', 'novo', 'último', 'atualizado'];
|
||||||
|
if (relativeTerms.some(term => normalizedChunk.includes(term))) {
|
||||||
|
contextualScore += 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (context.geographicContext?.location) {
|
||||||
|
const locationLower = context.geographicContext.location.toLowerCase();
|
||||||
|
if (normalizedChunk.includes(locationLower)) {
|
||||||
|
contextualScore += 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalScore += Math.min(contextualScore, 1) * 0.1;
|
||||||
|
weightSum += 0.1;
|
||||||
|
|
||||||
|
// Normalizar pelo peso total
|
||||||
|
const finalScore = weightSum > 0 ? totalScore / weightSum : 0;
|
||||||
|
|
||||||
|
// Garantir que score esteja entre 0 e 1
|
||||||
|
return Math.min(Math.max(finalScore, 0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula similaridade combinada (keywords + semântica)
|
||||||
|
*
|
||||||
|
* @param query - Query original
|
||||||
|
* @param context - Contexto analisado (opcional)
|
||||||
|
* @param chunk - Chunk de conteúdo
|
||||||
|
* @returns Score combinado (0-1)
|
||||||
|
*/
|
||||||
|
export function calculateCombinedSimilarity(
|
||||||
|
query: string,
|
||||||
|
chunk: string,
|
||||||
|
context?: QueryContext
|
||||||
|
): number {
|
||||||
|
// Score baseado em keywords
|
||||||
|
const keywordScore = calculateSimilarity(query, chunk);
|
||||||
|
|
||||||
|
// Se temos contexto, calcular score semântico também
|
||||||
|
if (context) {
|
||||||
|
const semanticScore = calculateSemanticSimilarity(context, chunk);
|
||||||
|
|
||||||
|
// Combinar: 60% keywords, 40% semântica
|
||||||
|
return keywordScore * 0.6 + semanticScore * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
return keywordScore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula scores de relevância para todos os chunks
|
||||||
|
*
|
||||||
|
* @param chunks - Array de chunks
|
||||||
|
* @param query - Query do usuário
|
||||||
|
* @param context - Contexto analisado (opcional, para ranqueamento semântico)
|
||||||
|
* @returns Chunks com scores atribuídos, ordenados por relevância
|
||||||
|
*/
|
||||||
|
export function scoreChunks(
|
||||||
|
chunks: Chunk[],
|
||||||
|
query: string,
|
||||||
|
context?: QueryContext
|
||||||
|
): Chunk[] {
|
||||||
|
if (!query || chunks.length === 0) {
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular score para cada chunk
|
||||||
|
const scoredChunks = chunks.map(chunk => ({
|
||||||
|
...chunk,
|
||||||
|
score: context
|
||||||
|
? calculateCombinedSimilarity(query, chunk.content, context)
|
||||||
|
: calculateSimilarity(query, chunk.content),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Ordenar por score (maior primeiro)
|
||||||
|
return scoredChunks.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seleciona os chunks mais relevantes que cabem no limite de tokens
|
||||||
|
*
|
||||||
|
* @param chunks - Chunks com scores
|
||||||
|
* @param maxTokens - Limite máximo de tokens
|
||||||
|
* @param query - Query do usuário (para contexto)
|
||||||
|
* @returns Chunks selecionados
|
||||||
|
*/
|
||||||
|
export function selectTopChunks(
|
||||||
|
chunks: Chunk[],
|
||||||
|
maxTokens: number,
|
||||||
|
query: string = ''
|
||||||
|
): Chunk[] {
|
||||||
|
if (chunks.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimar tokens (aproximação: 1 token ≈ 4 caracteres)
|
||||||
|
function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantir que pelo menos um chunk seja selecionado
|
||||||
|
const selected: Chunk[] = [];
|
||||||
|
let totalTokens = 0;
|
||||||
|
const maxChars = maxTokens * 4; // Converter tokens para chars
|
||||||
|
|
||||||
|
// Selecionar chunks por ordem de relevância até atingir limite
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const chunkTokens = estimateTokens(chunk.content);
|
||||||
|
|
||||||
|
if (totalTokens + chunkTokens <= maxTokens) {
|
||||||
|
selected.push(chunk);
|
||||||
|
totalTokens += chunkTokens;
|
||||||
|
} else {
|
||||||
|
// Se ainda não selecionou nenhum, forçar seleção do primeiro (mesmo que exceda)
|
||||||
|
if (selected.length === 0) {
|
||||||
|
selected.push(chunk);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se podemos adicionar parte do chunk
|
||||||
|
const remainingChars = maxChars - (totalTokens * 4);
|
||||||
|
if (remainingChars > 200) { // Mínimo de 200 chars para valer a pena
|
||||||
|
// Criar chunk truncado
|
||||||
|
const truncatedChunk: Chunk = {
|
||||||
|
...chunk,
|
||||||
|
content: chunk.content.substring(0, remainingChars) + '...',
|
||||||
|
};
|
||||||
|
selected.push(truncatedChunk);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtra chunks por score mínimo
|
||||||
|
*
|
||||||
|
* @param chunks - Chunks com scores
|
||||||
|
* @param minScore - Score mínimo (0-1)
|
||||||
|
* @returns Chunks que atendem ao score mínimo
|
||||||
|
*/
|
||||||
|
export function filterByMinScore(chunks: Chunk[], minScore: number = 0.1): Chunk[] {
|
||||||
|
return chunks.filter(chunk => chunk.score >= minScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combina scoring e seleção em uma única função
|
||||||
|
*
|
||||||
|
* @param chunks - Chunks originais
|
||||||
|
* @param query - Query do usuário
|
||||||
|
* @param maxTokens - Limite máximo de tokens
|
||||||
|
* @param minScore - Score mínimo (opcional)
|
||||||
|
* @param context - Contexto analisado (opcional, para ranqueamento semântico)
|
||||||
|
* @returns Chunks selecionados e ordenados por relevância
|
||||||
|
*/
|
||||||
|
export function scoreAndSelectChunks(
|
||||||
|
chunks: Chunk[],
|
||||||
|
query: string,
|
||||||
|
maxTokens: number,
|
||||||
|
minScore?: number,
|
||||||
|
context?: QueryContext
|
||||||
|
): Chunk[] {
|
||||||
|
// Calcular scores
|
||||||
|
let scoredChunks = scoreChunks(chunks, query, context);
|
||||||
|
|
||||||
|
// Filtrar por score mínimo se especificado
|
||||||
|
if (minScore !== undefined) {
|
||||||
|
scoredChunks = filterByMinScore(scoredChunks, minScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selecionar top chunks
|
||||||
|
return selectTopChunks(scoredChunks, maxTokens, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Utilitários para retry com backoff exponencial
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface RetryConfig {
|
||||||
|
maxAttempts: number; // Máximo de tentativas (padrão: 3)
|
||||||
|
initialDelay: number; // Delay inicial em ms (padrão: 1000)
|
||||||
|
maxDelay: number; // Delay máximo em ms (padrão: 10000)
|
||||||
|
backoffMultiplier: number; // Multiplicador do backoff (padrão: 2)
|
||||||
|
retryableErrors?: string[]; // Padrões de erro que devem ser retentados
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
||||||
|
maxAttempts: 3,
|
||||||
|
initialDelay: 1000,
|
||||||
|
maxDelay: 10000,
|
||||||
|
backoffMultiplier: 2,
|
||||||
|
retryableErrors: [
|
||||||
|
'timeout',
|
||||||
|
'network',
|
||||||
|
'connection',
|
||||||
|
'ECONNRESET',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'ENOTFOUND',
|
||||||
|
'sending request',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RetryResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
result?: T;
|
||||||
|
error?: Error;
|
||||||
|
attempts: number;
|
||||||
|
lastError?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifica se um erro é recuperável (deve ser retentado)
|
||||||
|
*/
|
||||||
|
export function isRetryableError(error: unknown, config: RetryConfig = DEFAULT_RETRY_CONFIG): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const errorString = errorMessage.toLowerCase();
|
||||||
|
|
||||||
|
// Verificar padrões de erro recuperáveis
|
||||||
|
if (config.retryableErrors) {
|
||||||
|
return config.retryableErrors.some(pattern =>
|
||||||
|
errorString.includes(pattern.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erros de rede/timeout são geralmente recuperáveis
|
||||||
|
return errorString.includes('timeout') ||
|
||||||
|
errorString.includes('network') ||
|
||||||
|
errorString.includes('connection');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula o delay para a próxima tentativa usando backoff exponencial
|
||||||
|
*/
|
||||||
|
function calculateDelay(attempt: number, config: RetryConfig): number {
|
||||||
|
const delay = config.initialDelay * Math.pow(config.backoffMultiplier, attempt - 1);
|
||||||
|
return Math.min(delay, config.maxDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executa uma função com retry e backoff exponencial
|
||||||
|
*/
|
||||||
|
export async function retryWithBackoff<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
config: Partial<RetryConfig> = {}
|
||||||
|
): Promise<RetryResult<T>> {
|
||||||
|
const finalConfig: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= finalConfig.maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result,
|
||||||
|
attempts: attempt,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
// Se não for erro recuperável, não tenta novamente
|
||||||
|
if (!isRetryableError(error, finalConfig)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: lastError,
|
||||||
|
attempts: attempt,
|
||||||
|
lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não for a última tentativa, aguarda antes de tentar novamente
|
||||||
|
if (attempt < finalConfig.maxAttempts) {
|
||||||
|
const delay = calculateDelay(attempt, finalConfig);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: lastError,
|
||||||
|
attempts: finalConfig.maxAttempts,
|
||||||
|
lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cria um timeout para uma promise
|
||||||
|
*/
|
||||||
|
export function withTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
timeoutMessage = 'Operation timed out'
|
||||||
|
): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula timeout adaptativo baseado no round
|
||||||
|
* Reduz 20% a cada round: 10s → 8s → 6s → 4.8s
|
||||||
|
*/
|
||||||
|
export function calculateAdaptiveTimeout(
|
||||||
|
round: number,
|
||||||
|
initialTimeout: number = 10000,
|
||||||
|
reductionRate: number = 0.2
|
||||||
|
): number {
|
||||||
|
const timeout = initialTimeout * Math.pow(1 - reductionRate, round - 1);
|
||||||
|
return Math.max(timeout, 5000); // Mínimo de 5s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula timeout por motor baseado em tentativas
|
||||||
|
* Reduz após cada falha: 10s → 7s → 5s
|
||||||
|
*/
|
||||||
|
export function calculateEngineTimeout(
|
||||||
|
attempt: number,
|
||||||
|
initialTimeout: number = 10000,
|
||||||
|
reductionRate: number = 0.3
|
||||||
|
): number {
|
||||||
|
const timeout = initialTimeout * Math.pow(1 - reductionRate, attempt - 1);
|
||||||
|
return Math.max(timeout, 3000); // Mínimo de 3s
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configurações otimizadas de timeout
|
||||||
|
*/
|
||||||
|
export const TIMEOUT_CONFIG = {
|
||||||
|
initialTimeout: 10000, // 10s (reduzido de 15s)
|
||||||
|
timeoutReductionPerRound: 0.2, // 20%
|
||||||
|
maxTimeoutPerQuery: 25000, // 25s total por query
|
||||||
|
maxTimeoutPerRound: 15000, // 15s por round
|
||||||
|
minTimeout: 5000, // Mínimo absoluto
|
||||||
|
minEngineTimeout: 3000, // Mínimo por motor
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Sanitiza conteúdo extraído da web antes de injetar no prompt da IA
|
||||||
|
* Remove caracteres de controle, limita tamanho e previne prompt injection
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SanitizeOptions {
|
||||||
|
maxLength?: number;
|
||||||
|
removeControlChars?: boolean;
|
||||||
|
removeHiddenText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: Required<SanitizeOptions> = {
|
||||||
|
maxLength: 10000, // Limite padrão de ~10k caracteres
|
||||||
|
removeControlChars: true,
|
||||||
|
removeHiddenText: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove caracteres de controle invisíveis que podem ser usados para prompt injection
|
||||||
|
*/
|
||||||
|
function removeControlCharacters(text: string): string {
|
||||||
|
// Remove caracteres de controle (0x00-0x1F, exceto \n, \r, \t)
|
||||||
|
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove texto oculto (zero-width spaces, directional markers, etc)
|
||||||
|
*/
|
||||||
|
function removeHiddenText(text: string): string {
|
||||||
|
// Remove zero-width spaces e outros caracteres invisíveis
|
||||||
|
return text
|
||||||
|
.replace(/[\u200B-\u200D\uFEFF]/g, '') // Zero-width spaces
|
||||||
|
.replace(/[\u202A-\u202E]/g, '') // Directional markers
|
||||||
|
.replace(/[\u2060-\u206F]/g, ''); // Word joiners e outros
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove tags HTML/XML que podem conter instruções maliciosas
|
||||||
|
*/
|
||||||
|
function removeSuspiciousTags(text: string): string {
|
||||||
|
// Remove tags script, style, meta que podem conter instruções
|
||||||
|
return text
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<meta[^>]*>/gi, '')
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, ''); // Remove comentários HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limita o tamanho do texto e adiciona indicador se foi truncado
|
||||||
|
*/
|
||||||
|
function limitLength(text: string, maxLength: number): string {
|
||||||
|
if (text.length <= maxLength) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncar em um ponto "seguro" (final de palavra/sentença)
|
||||||
|
const truncated = text.substring(0, maxLength);
|
||||||
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
|
const lastNewline = truncated.lastIndexOf('\n');
|
||||||
|
const cutPoint = Math.max(lastSpace, lastNewline);
|
||||||
|
|
||||||
|
if (cutPoint > maxLength * 0.9) {
|
||||||
|
// Se encontrou um bom ponto de corte próximo ao limite
|
||||||
|
return truncated.substring(0, cutPoint) + '\n\n[... conteúdo truncado para economizar tokens ...]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return truncated + '\n\n[... conteúdo truncado para economizar tokens ...]';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitiza conteúdo web para prevenir prompt injection e controlar tamanho
|
||||||
|
*
|
||||||
|
* @param content - Conteúdo bruto extraído da web
|
||||||
|
* @param options - Opções de sanitização
|
||||||
|
* @returns Conteúdo sanitizado e seguro para injetar no prompt
|
||||||
|
*/
|
||||||
|
export function sanitizeWebContent(
|
||||||
|
content: string,
|
||||||
|
options: SanitizeOptions = {}
|
||||||
|
): string {
|
||||||
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||||
|
let sanitized = content;
|
||||||
|
|
||||||
|
// 1. Remover tags suspeitas primeiro
|
||||||
|
sanitized = removeSuspiciousTags(sanitized);
|
||||||
|
|
||||||
|
// 2. Remover caracteres de controle
|
||||||
|
if (opts.removeControlChars) {
|
||||||
|
sanitized = removeControlCharacters(sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remover texto oculto
|
||||||
|
if (opts.removeHiddenText) {
|
||||||
|
sanitized = removeHiddenText(sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Normalizar espaços em branco excessivos
|
||||||
|
sanitized = sanitized.replace(/\s{3,}/g, ' '); // Múltiplos espaços -> um espaço
|
||||||
|
sanitized = sanitized.replace(/\n{4,}/g, '\n\n\n'); // Múltiplas quebras -> máximo 3
|
||||||
|
|
||||||
|
// 5. Limitar tamanho
|
||||||
|
sanitized = limitLength(sanitized, opts.maxLength);
|
||||||
|
|
||||||
|
return sanitized.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitiza múltiplas fontes e combina
|
||||||
|
*/
|
||||||
|
export function sanitizeWebSources(
|
||||||
|
sources: Array<{ markdown: string; url: string; title: string }>,
|
||||||
|
options: SanitizeOptions = {}
|
||||||
|
): string {
|
||||||
|
const sanitizedSources = sources.map((source, index) => {
|
||||||
|
const sanitized = sanitizeWebContent(source.markdown, options);
|
||||||
|
return `---\nFonte ${index + 1}: ${source.title}\nURL: ${source.url}\n---\n\n${sanitized}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizedSources.join('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user