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:
Evandro Fonseca Junior authored and GitHub committed 2025-11-27 22:54:28 -03:00
commit 0488a4205a
134 files changed
+41236 -504

No files matched your search

+185
View File
@@ -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
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
~/.tauri/ollahub.key
+309
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+284 -1
View File
@@ -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
View File
@@ -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>
+25
View File
@@ -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
View File
@@ -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
+551
View File
@@ -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>
);
}
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { useEffect, useState } from 'react';
import { UpdateDialog } from '@/components/modals/update-dialog';
import { useAppUpdater } from '@/hooks/use-app-updater';
import { useSettingsStore } from '@/store/settings-store';
export function AppUpdaterProvider() {
const { updateAvailable, checkUpdate } = useAppUpdater();
const [isOpen, setIsOpen] = useState(false);
const [hasChecked, setHasChecked] = useState(false);
const autoCheckEnabled = useSettingsStore((state) => state.autoCheckUpdates ?? true);
useEffect(() => {
if (!autoCheckEnabled || hasChecked) return;
// Verificar após um delay para não interferir no carregamento inicial
const timer = setTimeout(() => {
checkUpdate().then(() => {
setHasChecked(true);
});
}, 5000); // 5 segundos após o app carregar
return () => clearTimeout(timer);
}, [autoCheckEnabled, hasChecked, checkUpdate]);
useEffect(() => {
if (updateAvailable && !isOpen) {
setIsOpen(true);
}
}, [updateAvailable, isOpen]);
return (
<UpdateDialog
open={isOpen}
onOpenChange={setIsOpen}
/>
);
}
+224 -35
View File
@@ -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>
);
}
+214 -23
View File
@@ -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';
+2
View File
@@ -43,3 +43,5 @@ export function CodeBlock({ language, children }: CodeBlockProps) {
);
}
+64
View File
@@ -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>
);
}
+2
View File
@@ -136,3 +136,5 @@ export const markdownRenderers: Components = {
),
};
+419
View File
@@ -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>
);
}
+184
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+30 -2
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+137
View File
@@ -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>
);
}
+529
View File
@@ -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>
);
}
+127
View File
@@ -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>
);
}
+192
View File
@@ -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>
);
}
+50 -25
View File
@@ -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>
+134
View File
@@ -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>
);
}
+149
View File
@@ -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>
);
}
+290
View File
@@ -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>
);
}
+154
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+119
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+96 -28
View File
@@ -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>
);
}
+117
View File
@@ -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>
);
}
+164
View File
@@ -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>
</>
);
}
+254 -25
View File
@@ -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>
)
}
+153
View File
@@ -0,0 +1,153 @@
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Download, Loader2, X, Sparkles } from 'lucide-react';
import { useAppUpdater } from '@/hooks/use-app-updater';
import { useSettingsStore } from '@/store/settings-store';
interface UpdateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UpdateDialog({ open, onOpenChange }: UpdateDialogProps) {
const {
currentVersion,
updateAvailable,
updateInfo,
isChecking,
isDownloading,
downloadProgress,
error,
installUpdate,
} = useAppUpdater();
const [dontShowAgain, setDontShowAgain] = useState(false);
const setAutoCheckUpdates = useSettingsStore((state) => state.setAutoCheckUpdates);
const handleInstall = async () => {
if (dontShowAgain) {
setAutoCheckUpdates(false);
}
await installUpdate();
};
const handleClose = () => {
if (dontShowAgain) {
setAutoCheckUpdates(false);
}
onOpenChange(false);
};
if (!updateAvailable && !isChecking) {
return null;
}
return (
<Dialog open={open && updateAvailable} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
Atualização Disponível
</DialogTitle>
<DialogDescription>
Uma nova versão do OllaHub está disponível para download.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Versão Atual</span>
<span className="font-mono font-medium">{currentVersion}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Nova Versão</span>
<span className="font-mono font-medium text-primary">
{updateInfo?.version}
</span>
</div>
</div>
{updateInfo?.body && (
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground line-clamp-3">
{updateInfo.body}
</p>
</div>
)}
{isDownloading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Baixando atualização...</span>
<span className="font-medium">{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
</div>
)}
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="dont-show-updates"
checked={dontShowAgain}
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
/>
<Label
htmlFor="dont-show-updates"
className="text-sm font-normal cursor-pointer"
>
Não verificar atualizações automaticamente
</Label>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={handleClose}
disabled={isDownloading}
>
<X className="h-4 w-4 mr-2" />
Depois
</Button>
<Button
onClick={handleInstall}
disabled={isDownloading || isChecking}
>
{isDownloading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Baixando...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Atualizar Agora
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -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>
);
}
+97
View File
@@ -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>
);
}
+271
View File
@@ -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>
);
}
+200
View File
@@ -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>
);
}
+73
View File
@@ -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>
);
}
+139
View File
@@ -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>
);
}
+72 -13
View File
@@ -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>
+2
View File
@@ -10,3 +10,5 @@ export function ThemeProvider({
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+123
View File
@@ -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>
);
}
+4 -4
View File
@@ -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: {
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+2
View File
@@ -197,3 +197,5 @@ export {
DropdownMenuRadioGroup,
}
+26
View File
@@ -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 }
+27
View File
@@ -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 }
+2
View File
@@ -25,3 +25,5 @@ Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+29
View File
@@ -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>
);
}
+3 -1
View File
@@ -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,
}
+27
View File
@@ -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 }
+65
View File
@@ -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>
);
}
+30
View File
@@ -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 }
+2
View File
@@ -21,3 +21,5 @@ Textarea.displayName = "Textarea"
export { Textarea }
+29
View File
@@ -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 }
+46
View File
@@ -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).`
};
+127
View File
@@ -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.`
};
+46 -21
View File
@@ -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.
+34
View File
@@ -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`
+118
View File
@@ -0,0 +1,118 @@
# Configuração do Sistema de Atualização Automática
Este documento explica como configurar o sistema de atualização automática do OllaHub.
## Pré-requisitos
1. Repositório GitHub configurado
2. Rust e Cargo instalados
3. Tauri CLI instalado
## Passo 1: Gerar Chaves de Assinatura
Execute o script para gerar as chaves:
```bash
chmod +x scripts/generate-updater-keys.sh
./scripts/generate-updater-keys.sh
```
Ou manualmente:
```bash
mkdir -p ~/.tauri
cargo tauri signer generate -w ~/.tauri/ollahub.key
```
## Passo 2: Configurar Chave Pública
1. Copie a chave pública gerada (está em `~/.tauri/ollahub.key.pub`)
2. Edite `src-tauri/tauri.conf.json`
3. Substitua `YOUR_PUBLIC_KEY_HERE` pela chave pública
4. Atualize o endpoint com seu repositório GitHub:
```json
{
"updater": {
"active": true,
"endpoints": [
"https://api.github.com/repos/SEU_USUARIO/SEU_REPOSITORIO/releases/latest"
],
"dialog": true,
"pubkey": "SUA_CHAVE_PUBLICA_AQUI"
}
}
```
## Passo 3: Configurar GitHub Secrets
1. Vá em **Settings** > **Secrets and variables** > **Actions**
2. Clique em **New repository secret**
3. Nome: `TAURI_UPDATER_PRIVATE_KEY`
4. Valor: Cole o conteúdo completo do arquivo `~/.tauri/ollahub.key`
## Passo 4: Atualizar Workflow (se necessário)
O workflow `.github/workflows/release.yml` já está configurado para:
- Buildar para Windows, macOS e Linux
- Assinar binários com a chave privada
- Criar release automaticamente quando há push em `main`/`master`
## Como Funciona
1. **Build Automático**: Quando você faz push para `main`/`master`, o GitHub Actions:
- Builda o app para todas as plataformas
- Assina os binários
- Cria uma release no GitHub
2. **Verificação de Atualizações**: O app verifica automaticamente:
- 5 segundos após iniciar (primeira verificação)
- A cada 6 horas (verificação periódica)
- Manualmente via botão nas configurações
3. **Instalação**: Quando há atualização disponível:
- Usuário recebe notificação
- Pode escolher atualizar agora ou depois
- Download e instalação são automáticos
- App reinicia automaticamente após instalação
## Bump de Versão
Para criar uma nova versão:
```bash
# Patch (0.1.0 -> 0.1.1)
pnpm run bump-version patch --commit
# Minor (0.1.0 -> 0.2.0)
pnpm run bump-version minor --commit
# Major (0.1.0 -> 1.0.0)
pnpm run bump-version major --commit
```
Depois, faça push:
```bash
git push && git push --tags
```
O GitHub Actions criará automaticamente a release.
## Troubleshooting
### Erro: "Failed to check for updates"
- Verifique se o endpoint no `tauri.conf.json` está correto
- Verifique se há releases públicas no GitHub
- Verifique a conexão com a internet
### Erro: "Signature verification failed"
- Verifique se a chave pública no `tauri.conf.json` está correta
- Verifique se a chave privada no GitHub Secret está correta
- Regere as chaves se necessário
### Release não é criada automaticamente
- Verifique se o push foi feito para `main` ou `master`
- Verifique os logs do GitHub Actions
- Verifique se as permissões do workflow estão corretas
+142
View File
@@ -0,0 +1,142 @@
import { useState, useEffect, useCallback } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { useSettingsStore } from '@/store/settings-store';
import { check as checkForUpdate } from '@tauri-apps/plugin-updater';
export interface UpdateInfo {
version: string;
date?: string;
body?: string;
}
export interface AppUpdaterState {
currentVersion: string;
updateAvailable: boolean;
updateInfo: UpdateInfo | null;
isChecking: boolean;
isDownloading: boolean;
downloadProgress: number;
error: string | null;
checkUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
}
export function useAppUpdater(): AppUpdaterState {
const [currentVersion, setCurrentVersion] = useState<string>('');
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const autoCheckEnabled = useSettingsStore((state) => state.autoCheckUpdates ?? true);
// Obter versão atual
useEffect(() => {
const fetchVersion = async () => {
try {
const version = await getVersion();
setCurrentVersion(version);
} catch (err) {
console.error('Failed to get app version:', err);
setCurrentVersion('0.1.0');
}
};
fetchVersion();
}, []);
// Verificar atualizações
const checkUpdate = useCallback(async () => {
setIsChecking(true);
setError(null);
try {
const update = await checkForUpdate();
if (update?.available) {
setUpdateAvailable(true);
setUpdateInfo({
version: update.version,
date: update.date,
body: update.body,
});
} else {
setUpdateAvailable(false);
setUpdateInfo(null);
}
} catch (err) {
console.error('Failed to check for updates:', err);
setError(err instanceof Error ? err.message : 'Erro ao verificar atualizações');
setUpdateAvailable(false);
} finally {
setIsChecking(false);
}
}, []);
// Instalar atualização
const installUpdate = useCallback(async () => {
if (!updateAvailable) return;
setIsDownloading(true);
setError(null);
setDownloadProgress(0);
try {
const update = await checkForUpdate();
if (update?.available) {
// Escutar progresso do download
const unlisten = await update.on('download-progress', (progress: any) => {
setDownloadProgress(progress.progress || 0);
});
// Baixar e instalar atualização
await update.downloadAndInstall();
unlisten();
// Reiniciar aplicativo após instalação
await update.installAndRestart();
}
} catch (err) {
console.error('Failed to install update:', err);
setError(err instanceof Error ? err.message : 'Erro ao instalar atualização');
setIsDownloading(false);
}
}, [updateAvailable]);
// Verificação automática periódica (a cada 6 horas)
useEffect(() => {
if (!autoCheckEnabled) return;
// Verificar imediatamente ao montar (com delay para não interferir no carregamento)
const initialTimer = setTimeout(() => {
checkUpdate();
}, 5000); // 5 segundos após o app carregar
// Verificar periodicamente
const interval = setInterval(() => {
checkUpdate();
}, 6 * 60 * 60 * 1000); // 6 horas
return () => {
clearTimeout(initialTimer);
clearInterval(interval);
};
}, [autoCheckEnabled, checkUpdate]);
return {
currentVersion,
updateAvailable,
updateInfo,
isChecking,
isDownloading,
downloadProgress,
error,
checkUpdate,
installUpdate,
};
}
+59
View File
@@ -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 };
}
+190
View File
@@ -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 }))
};
}
+560
View File
@@ -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
View File
@@ -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 };
}
+577
View File
@@ -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 }))
};
}
+6 -2
View File
@@ -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 || []
};
}
+2
View File
@@ -41,3 +41,5 @@ export function useLocalModels() {
return { models, loading, refresh: fetchModels, deleteModel };
}
+73
View File
@@ -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,
};
}
+133
View File
@@ -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
View File
@@ -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');
}
}
};
+257
View File
@@ -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,
};
}
+58
View File
@@ -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 };
}
+2
View File
@@ -68,3 +68,5 @@ export function usePromptGenerator() {
return { generatePrompt, isGenerating };
}
+103
View File
@@ -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;
}
+91
View File
@@ -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 };
}
+182
View File
@@ -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,
};
}
+2
View File
@@ -27,3 +27,5 @@ export function useSystemMonitor() {
return stats;
}
+27
View File
@@ -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,
};
}
+248
View File
@@ -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,
};
}
+387
View File
@@ -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,
};
}
+320
View File
@@ -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,
};
}
+276
View File
@@ -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',
};
}
+97
View File
@@ -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}`);
}
}
+264
View File
@@ -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,
};
}
+23
View File
@@ -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();
}
+133
View File
@@ -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,
};
}
+294
View File
@@ -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;
}
+177
View File
@@ -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
);
}
+217
View File
@@ -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,
};
}
+8
View File
@@ -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 {
+346
View File
@@ -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);
}
+169
View File
@@ -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;
+122
View File
@@ -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