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
+41236
-504
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
|
||||
*.tsbuildinfo
|
||||
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
|
||||
|
||||
+1472
-139
File diff suppressed because it is too large.
Load diff
+284
-1
@@ -2,6 +2,7 @@
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant hc (&:is([data-high-contrast=true] *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
@@ -37,10 +38,23 @@
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--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-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--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 {
|
||||
@@ -76,6 +90,11 @@
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--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 {
|
||||
@@ -110,13 +129,277 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--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 {
|
||||
* {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@apply border-border outline-ring/50;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@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 "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { AppUpdaterProvider } from "@/components/app-updater-provider";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -35,6 +36,7 @@ export default function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<AppUpdaterProvider />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,13 +4,36 @@ import { useOllamaCheck } from "@/hooks/use-ollama-check";
|
||||
import { Hero } from "@/components/landing/hero";
|
||||
import { InstallModal } from "@/components/modals/install-modal";
|
||||
import { StoppedCard } from "@/components/landing/stopped-card";
|
||||
import { WelcomeNotification } from "@/components/notifications/welcome-notification";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Home() {
|
||||
const { status, check } = useOllamaCheck();
|
||||
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 setupComplete = localStorage.getItem("ollahub_setup_complete");
|
||||
if (setupComplete) {
|
||||
@@ -31,6 +54,8 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
{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 { HardwareScan } from "@/components/setup/hardware-scan";
|
||||
import { GpuSelector } from "@/components/setup/gpu-selector";
|
||||
import { ModelSelector } from "@/components/setup/model-selector";
|
||||
import { getRecommendation } from "@/lib/recommendation";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function SetupPage() {
|
||||
const { specs, loading } = useHardware();
|
||||
const { specs, loading, gpus } = useHardware();
|
||||
const router = useRouter();
|
||||
|
||||
const handleComplete = () => {
|
||||
@@ -26,6 +27,12 @@ export default function SetupPage() {
|
||||
|
||||
<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 && (
|
||||
<div className="animate-in slide-in-from-bottom-8 duration-700 delay-300 fill-mode-backwards">
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+224
-35
@@ -1,17 +1,83 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Send, Square } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Send, Square, Globe, WifiOff, ChevronUp, AlertCircle, GraduationCap, Newspaper, Terminal, TrendingUp } from "lucide-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 {
|
||||
onSend: (message: string) => void;
|
||||
onStop: () => void;
|
||||
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 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) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -21,7 +87,7 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
if (input.trim() && !isLoading) {
|
||||
if (input.trim() && !isLoading && isValid) {
|
||||
onSend(input);
|
||||
setInput("");
|
||||
}
|
||||
@@ -30,43 +96,166 @@ export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div className="p-4 border-t bg-background">
|
||||
<div className="relative flex items-end gap-2 max-w-3xl mx-auto">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Digite sua mensagem..."
|
||||
className="min-h-[50px] max-h-[200px] resize-none pr-12 py-3"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2">
|
||||
{isLoading ? (
|
||||
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim()}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="w-full bg-transparent">
|
||||
<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
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Pergunte qualquer coisa..."
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 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 ? (
|
||||
<Button
|
||||
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
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !isValid}
|
||||
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 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 className="text-xs text-center text-muted-foreground mt-2">
|
||||
OllaHub pode cometer erros. Verifique informações importantes.
|
||||
</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 remarkGfm from 'remark-gfm';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Message } from "@/hooks/use-chat";
|
||||
import { Bot, User } from "lucide-react";
|
||||
import { markdownRenderers } from "./markdown-renderers";
|
||||
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 [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 (
|
||||
<div className={cn(
|
||||
"group flex w-full gap-4 p-6 relative",
|
||||
isUser ? "bg-background" : "bg-muted/30"
|
||||
)}>
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<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(
|
||||
"w-8 h-8 rounded-sm flex items-center justify-center",
|
||||
isUser ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground"
|
||||
"w-8 h-8 rounded-full flex items-center justify-center shadow-sm ring-1 ring-border/50 backdrop-blur-sm",
|
||||
isUser
|
||||
? "bg-background text-muted-foreground"
|
||||
: "bg-primary/10 text-primary ring-primary/20"
|
||||
)}>
|
||||
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden min-w-0">
|
||||
<div className="prose dark:prose-invert max-w-none break-words">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownRenderers}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-5 h-5" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Toolbar - Absolute positioned or flex end */}
|
||||
{!isUser && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<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
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownRenderers}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isUser && !isStreaming && (
|
||||
<div className={cn(
|
||||
"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} />
|
||||
</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,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Copy, FileJson, FileText, MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Copy, FileJson, FileText, MoreHorizontal, Contrast } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string;
|
||||
@@ -15,6 +15,16 @@ interface MessageActionsProps {
|
||||
|
||||
export function MessageActions({ content, role }: MessageActionsProps) {
|
||||
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) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
@@ -73,9 +83,27 @@ export function MessageActions({ content, role }: MessageActionsProps) {
|
||||
<FileJson className="mr-2 h-4 w-4" />
|
||||
<span>Copiar JSON</span>
|
||||
</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>
|
||||
</DropdownMenu>
|
||||
</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 { Textarea } from "@/components/ui/textarea";
|
||||
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 { useState, useEffect } from "react";
|
||||
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">
|
||||
Modelo para Geração
|
||||
</label>
|
||||
<Select
|
||||
value={selectedModel || undefined}
|
||||
onValueChange={setSelectedModel}
|
||||
disabled={loading || models.length === 0}
|
||||
>
|
||||
<SelectTrigger id="model-select">
|
||||
<SelectValue placeholder={
|
||||
loading
|
||||
? "Carregando modelos..."
|
||||
: models.length === 0
|
||||
? "Nenhum modelo disponível"
|
||||
: "Selecione um modelo..."
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.length === 0 ? (
|
||||
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
|
||||
) : (
|
||||
models.map(m => (
|
||||
<SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
value={selectedModel || undefined}
|
||||
onValueChange={setSelectedModel}
|
||||
disabled={loading || models.length === 0}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger id="model-select" className="min-w-0 max-w-full">
|
||||
<SelectValue placeholder={
|
||||
loading
|
||||
? "Carregando modelos..."
|
||||
: models.length === 0
|
||||
? "Nenhum modelo disponível"
|
||||
: "Selecione um modelo..."
|
||||
} className="truncate" />
|
||||
</SelectTrigger>
|
||||
</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 ? (
|
||||
<SelectItem value="" disabled>Nenhum modelo disponível</SelectItem>
|
||||
) : (
|
||||
models.map(m => (
|
||||
<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>
|
||||
</Select>
|
||||
</TooltipProvider>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
|
||||
</p>
|
||||
@@ -130,7 +155,7 @@ export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: Promp
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
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}
|
||||
/>
|
||||
</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 { Progress } from "@/components/ui/progress";
|
||||
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 { useLocalModels } from "@/hooks/use-local-models";
|
||||
import { useState } from "react";
|
||||
import { ModelDownloadDialog } from "./model-download-dialog";
|
||||
|
||||
export function SystemPanel() {
|
||||
const stats = useSystemMonitor();
|
||||
const { models, deleteModel, refresh } = useLocalModels();
|
||||
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
||||
|
||||
const ramUsagePercent = stats
|
||||
? (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">
|
||||
Modelos Instalados
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
|
||||
Atualizar
|
||||
</Button>
|
||||
<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">
|
||||
Atualizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{models.map((model) => (
|
||||
<Card key={model.id} className="overflow-hidden">
|
||||
<CardContent className="p-3 flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm truncate" title={model.name}>
|
||||
{model.name}
|
||||
{models.map((model) => {
|
||||
// Tentar detectar se é modelo local/GGUF (heurística: modelos GGUF geralmente têm nomes específicos)
|
||||
// Por enquanto, vamos mostrar todos como disponíveis
|
||||
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}>
|
||||
{model.name}
|
||||
</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">
|
||||
<Database className="w-3 h-3" />
|
||||
{model.size}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Database className="w-3 h-3" />
|
||||
{model.size}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => deleteModel(model.name)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10 flex-shrink-0"
|
||||
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" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remover modelo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{models.length === 0 && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
Nenhum modelo encontrado.
|
||||
<div className="text-center text-sm text-muted-foreground py-8 space-y-2">
|
||||
<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>
|
||||
|
||||
<ModelDownloadDialog
|
||||
open={showDownloadDialog}
|
||||
onOpenChange={setShowDownloadDialog}
|
||||
onSuccess={(modelName) => {
|
||||
refresh();
|
||||
setShowDownloadDialog(false);
|
||||
}}
|
||||
/>
|
||||
</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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,7 +10,12 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
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 {
|
||||
open: boolean;
|
||||
@@ -15,6 +23,162 @@ interface 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 (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
@@ -22,29 +186,41 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
<DialogTitle>Ollama não encontrado</DialogTitle>
|
||||
<DialogDescription>
|
||||
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>
|
||||
</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">
|
||||
<TabsTrigger value="mac">macOS</TabsTrigger>
|
||||
<TabsTrigger value="linux">Linux</TabsTrigger>
|
||||
<TabsTrigger value="windows">Windows</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="mac" className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">1. Download e Instalação</h3>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<a href="https://ollama.com/download/Ollama-darwin.zip" target="_blank" rel="noopener noreferrer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Baixar para macOS
|
||||
</a>
|
||||
</Button>
|
||||
<DownloadButton os="mac" label="Baixar para macOS" />
|
||||
<DownloadProgress os="mac" />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Após instalar, abra o terminal e digite <code>ollama serve</code> se necessário.
|
||||
{downloadState.downloadError && activeTab === 'mac' && (
|
||||
<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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="linux" className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Instalação via Terminal</h3>
|
||||
@@ -52,35 +228,88 @@ export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
|
||||
<code className="text-sm break-all">curl -fsSL https://ollama.com/install.sh | sh</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<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.
|
||||
</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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="windows" className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">1. Download do Instalador</h3>
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<a href="https://ollama.com/download/OllamaSetup.exe" target="_blank" rel="noopener noreferrer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Baixar para Windows
|
||||
</a>
|
||||
</Button>
|
||||
<DownloadButton os="windows" label="Baixar para Windows" />
|
||||
<DownloadProgress os="windows" />
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Execute o instalador baixado e siga as instruções na tela.
|
||||
{downloadState.downloadError && activeTab === 'windows' && (
|
||||
<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>
|
||||
</TabsContent>
|
||||
</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">
|
||||
<Button onClick={onCheckAgain}>
|
||||
Verificar Novamente
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Chamar verificação completa (instalação + execução)
|
||||
onCheckAgain();
|
||||
}}
|
||||
>
|
||||
Verificar Novamente
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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;
|
||||
}
|
||||
|
||||
interface DownloadInfo {
|
||||
status: string;
|
||||
percent?: number;
|
||||
downloaded?: string;
|
||||
total?: string;
|
||||
speed?: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps) {
|
||||
const [selectedModelId, setSelectedModelId] = useState(recommendation.modelId);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [downloadStatus, setDownloadStatus] = useState("");
|
||||
const [downloadInfo, setDownloadInfo] = useState<DownloadInfo | null>(null);
|
||||
|
||||
const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0];
|
||||
|
||||
@@ -29,25 +39,60 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen<string>('download-progress', (event) => {
|
||||
const line = event.payload;
|
||||
setDownloadStatus(line);
|
||||
const payload = event.payload;
|
||||
|
||||
// Simple heuristic parsing for progress
|
||||
// Ollama output example: "pulling manifest", "downloading 10%", "verifying sha256 digest"
|
||||
if (line.includes('%')) {
|
||||
const match = line.match(/(\d+)%/);
|
||||
if (match) {
|
||||
setDownloadProgress(parseInt(match[1], 10));
|
||||
// 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(() => {
|
||||
setIsInstalled(true);
|
||||
setIsDownloading(false);
|
||||
checkInstallation(selectedModelId);
|
||||
}, 500);
|
||||
}
|
||||
} else if (line.includes('success')) {
|
||||
setDownloadProgress(100);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten.then(f => f());
|
||||
};
|
||||
}, []);
|
||||
}, [selectedModelId]);
|
||||
|
||||
const checkInstallation = async (modelId: string) => {
|
||||
try {
|
||||
@@ -61,13 +106,15 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
setDownloadProgress(0);
|
||||
setDownloadStatus("Iniciando download...");
|
||||
try {
|
||||
await invoke('pull_model', { name: selectedModelId });
|
||||
setIsInstalled(true);
|
||||
setIsDownloading(false);
|
||||
// O status será atualizado pelo listener quando o download completar
|
||||
// Não precisamos setar aqui pois o listener já faz isso
|
||||
} catch (error) {
|
||||
console.error("Download failed", error);
|
||||
setDownloadStatus("Falha no download. Tente novamente.");
|
||||
setDownloadProgress(0);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
@@ -128,6 +175,18 @@ export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -10,3 +10,5 @@ export function ThemeProvider({
|
||||
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",
|
||||
},
|
||||
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",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
lg: "h-12 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-10",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
"icon-lg": "size-12",
|
||||
},
|
||||
},
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -157,3 +157,5 @@ export {
|
||||
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 }
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
- 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.
|
||||
## DIRETRIZES DE RESPOSTA (RAG)
|
||||
|
||||
### 2. Blocos de Código
|
||||
- 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`.
|
||||
### 1. Contextualização Temporal Obrigatória
|
||||
|
||||
### 3. Ênfase e Destaque
|
||||
- Use **Negrito** para conceitos chave ou avisos importantes.
|
||||
- Use *Itálico* para termos estrangeiros ou ênfase sutil.
|
||||
- Use > Blockquotes para citações ou notas laterais.
|
||||
- **SEMPRE** inicie respostas sobre notícias, eventos ou fatos atuais citando a data atual explicitamente.
|
||||
- Exemplo: "Com base nas informações de hoje, [DATA], os principais destaques são..."
|
||||
- Use a data fornecida no prompt como referência absoluta para "hoje", "ontem", "amanhã".
|
||||
|
||||
### 4. Links
|
||||
- Formate links corretamente: `[Texto do Link](URL)`.
|
||||
### 2. Densidade e Profundidade
|
||||
|
||||
## ESTILO DE RESPOSTA
|
||||
- Seja direto e objetivo. Evite preâmbulos desnecessários ("Claro, aqui está...").
|
||||
- Se a resposta for longa, comece com um resumo executivo.
|
||||
- Ao explicar código, comente o que cada parte faz.
|
||||
- **NUNCA** responda com listas de tópicos curtos e vagos (ex: "Houve um protesto").
|
||||
- Cada ponto deve conter detalhes específicos: **Números, Nomes Próprios, Locais e Citações Diretas** do contexto.
|
||||
- Explique o *contexto* da notícia, não apenas a manchete.
|
||||
- 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
|
||||
};
|
||||
}
|
||||
+503
-63
@@ -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 {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
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() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
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);
|
||||
|
||||
// Função para atualizar o estado visual com o conteúdo acumulado
|
||||
const flushToState = () => {
|
||||
renderScheduledRef.current = false;
|
||||
const displayContent = stripMetadataTag(rawContentRef.current);
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.role === 'assistant') {
|
||||
return [...prev.slice(0, -1), { ...last, content: displayContent }];
|
||||
}
|
||||
return [...prev, { role: 'assistant', content: displayContent }];
|
||||
});
|
||||
};
|
||||
|
||||
const sendMessage = async (content: string, model: string, systemPrompt?: string) => {
|
||||
// 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;
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
}).then(unlisten => {
|
||||
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);
|
||||
|
||||
const newMessages: Message[] = [
|
||||
...messages,
|
||||
{ role: 'user', content }
|
||||
];
|
||||
// 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)
|
||||
const apiMessages = systemPrompt
|
||||
? [{ role: 'system', content: systemPrompt }, ...newMessages]
|
||||
: newMessages;
|
||||
// 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);
|
||||
}
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
// 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) + '...');
|
||||
}
|
||||
} else {
|
||||
console.error('❌ CRITICAL: System Message is MISSING in payload!');
|
||||
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 {
|
||||
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 => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.role === 'assistant') {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, content: last.content + json.message.content }
|
||||
];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
if (json.done) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing chunk', e);
|
||||
}
|
||||
// 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) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('Request aborted');
|
||||
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 {
|
||||
console.error('Chat error:', error);
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: 'Error: Failed to get response from Ollama.' }]);
|
||||
// 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 {
|
||||
setIsLoading(false);
|
||||
abortControllerRef.current = null;
|
||||
@@ -99,8 +537,10 @@ export function useChat() {
|
||||
|
||||
const clearChat = () => {
|
||||
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 { invoke } from '@tauri-apps/api/core';
|
||||
import { SystemSpecs } from '@/lib/recommendation';
|
||||
import { SystemSpecs, GpuInfo } from '@/lib/recommendation';
|
||||
|
||||
export function useHardware() {
|
||||
const [specs, setSpecs] = useState<SystemSpecs | null>(null);
|
||||
@@ -23,6 +23,10 @@ export function useHardware() {
|
||||
fetchSpecs();
|
||||
}, []);
|
||||
|
||||
return { specs, loading };
|
||||
return {
|
||||
specs,
|
||||
loading,
|
||||
gpus: specs?.gpus || []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,3 +41,5 @@ export function useLocalModels() {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
+28
-19
@@ -7,28 +7,37 @@ export function useOllamaCheck() {
|
||||
const [status, setStatus] = useState<OllamaStatus>('checking');
|
||||
|
||||
const check = async () => {
|
||||
// Don't set checking here if we want to silently revalidate,
|
||||
// but for initial load or explicit re-check it is fine.
|
||||
// Only set checking if it's not already in a known state to avoid flicker?
|
||||
// For now simple is better.
|
||||
setStatus('checking');
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_ollama_installed');
|
||||
if (!installed) {
|
||||
setStatus('not_installed');
|
||||
return;
|
||||
}
|
||||
|
||||
const running = await invoke<boolean>('check_ollama_running');
|
||||
if (!running) {
|
||||
setStatus('installed_stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('running');
|
||||
// Usar verificação completa que checa instalação E execução
|
||||
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);
|
||||
// If invoke fails (e.g. backend not ready), we might want to handle gracefully
|
||||
setStatus('not_installed');
|
||||
// Se falhar, tentar verificação individual como fallback
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_ollama_installed');
|
||||
if (!installed) {
|
||||
setStatus('not_installed');
|
||||
return;
|
||||
}
|
||||
|
||||
const running = await invoke<boolean>('check_ollama_running');
|
||||
if (!running) {
|
||||
setStatus('installed_stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('running');
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback check also failed:', fallbackError);
|
||||
setStatus('not_installed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
total_memory: number; // Bytes
|
||||
cpu_count: number;
|
||||
os_name: string;
|
||||
gpus: GpuInfo[];
|
||||
}
|
||||
|
||||
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