feat: add emoji-regex and remove-accents dependencies; enhance chat and setup pages

- Added `emoji-regex` and `remove-accents` packages to improve text processing capabilities.
- Implemented automatic Ollama startup in the chat page for better user experience.
- Enhanced setup page to include GPU selection based on hardware specifications.
- Updated chat input to support real-time validation and improved user feedback.
- Refactored message handling in chat to incorporate new processing features.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-26 16:12:16 -03:00
1 parent 4275295e4f
commit 84ea2dec19
51 files changed
+7857 -573

No files matched your search

+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 }))
};
}
+275 -118
View File
@@ -3,10 +3,34 @@ import { invoke } from '@tauri-apps/api/core';
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;
metadata?: any | ThinkingMessageMetadata;
}
interface ToolCall {
@@ -209,10 +233,13 @@ export function useChat() {
) => {
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)
@@ -301,130 +328,260 @@ export function useChat() {
abortControllerRef.current = new AbortController();
try {
const response = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: apiMessages,
stream: true,
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) throw new Error('Failed to send message');
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Reset raw content buffer for this message
rawContentRef.current = '';
// Add empty assistant message to start streaming into
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
let streamFinished = false;
let metadataProcessed = false;
while (true) {
const { done, value } = await reader.read();
if (done) {
streamFinished = true;
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) {
// Accumulate raw content (including metadata) in ref
rawContentRef.current += json.message.content;
setMessages(prev => {
const last = prev[prev.length - 1];
if (last.role === 'assistant') {
// For display: strip incomplete metadata tag if present (opening tag but not closed)
// This prevents showing "<metadata>..." in the UI while streaming
const displayContent = stripMetadataTag(rawContentRef.current);
return [
...prev.slice(0, -1),
{ ...last, content: displayContent }
];
}
return prev;
});
}
if (json.done) {
streamFinished = true;
metadataProcessed = true;
// Process metadata FIRST before setting isLoading to false
// This prevents race condition with saveSession useEffect
setMessages(prev => {
const last = prev[prev.length - 1];
if (last.role === 'assistant') {
// Parse metadata from raw accumulated content
const rawContent = rawContentRef.current;
const { content, metadata } = parseMetadata(rawContent);
// Fallback: if content is empty after parse but we had content before,
// keep the stripped version (without metadata tag) to preserve user-visible text
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
// Clear ref for next message
rawContentRef.current = '';
return [
...prev.slice(0, -1),
{ ...last, content: finalContent, metadata }
];
}
return prev;
});
// Set loading to false AFTER processing metadata
setIsLoading(false);
break; // Exit inner loop when done
}
} catch (e) {
console.error('Error parsing chunk', e);
// Função auxiliar para tentar enviar mensagem (usada no retry)
const attemptSend = async (): Promise<void> => {
// 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
}
}
// If stream finished without explicit json.done, process metadata anyway
if (streamFinished && !metadataProcessed && rawContentRef.current) {
// Timeout configurável (padrão 5 minutos)
const timeoutMs = 5 * 60 * 1000; // 5 minutos
const timeoutId = setTimeout(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}, timeoutMs);
let response: Response;
try {
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,
});
} catch (fetchError: any) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
throw new Error('RequestTimeout');
}
if (fetchError.message?.includes('Failed to fetch') || fetchError.message?.includes('NetworkError')) {
throw new Error('ConnectionError');
}
throw fetchError;
}
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 404) {
throw new Error('ModelNotFound');
}
if (response.status === 500) {
throw new Error('ServerError');
}
throw new Error(`HTTPError: ${response.status}`);
}
if (!response.body) throw new Error('NoResponseBody');
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Reset raw content buffer for this message
rawContentRef.current = '';
// Add empty assistant message to start streaming into
// Verificar se já existe uma mensagem vazia do assistente (adicionada pelo handleSend)
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
const rawContent = rawContentRef.current;
const { content, metadata } = parseMetadata(rawContent);
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
rawContentRef.current = '';
return [
...prev.slice(0, -1),
{ ...last, content: finalContent, metadata }
];
if (last && last.role === 'assistant' && last.content === '') {
// Já existe mensagem vazia, não adicionar outra
return prev;
}
return prev;
// Adicionar mensagem vazia do assistente
return [...prev, { role: 'assistant', content: '' }];
});
setIsLoading(false);
} else if (streamFinished && !metadataProcessed) {
// Stream finished but no content to process, just set loading to false
setIsLoading(false);
let streamFinished = false;
let metadataProcessed = false;
while (true) {
const { done, value } = await reader.read();
if (done) {
streamFinished = true;
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter(line => line.trim() !== '');
for (const line of lines) {
// Validação básica: verificar se a linha parece ser JSON válido
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.length === 0) {
continue; // Linha vazia, ignorar
}
// Verificar se começa com { e termina com } (formato básico de JSON)
if (!trimmedLine.startsWith('{') || !trimmedLine.endsWith('}')) {
// Linha parcial ou inválida, ignorar silenciosamente
// Isso pode acontecer quando o streaming divide JSON em múltiplas chunks
continue;
}
try {
const json = JSON.parse(trimmedLine);
if (json.message?.content) {
// Accumulate raw content (including metadata) in ref
rawContentRef.current += json.message.content;
setMessages(prev => {
const last = prev[prev.length - 1];
if (last.role === 'assistant') {
// For display: strip incomplete metadata tag if present (opening tag but not closed)
// This prevents showing "<metadata>..." in the UI while streaming
const displayContent = stripMetadataTag(rawContentRef.current);
return [
...prev.slice(0, -1),
{ ...last, content: displayContent }
];
}
return prev;
});
}
if (json.done) {
streamFinished = true;
metadataProcessed = true;
// Process metadata FIRST before setting isLoading to false
// This prevents race condition with saveSession useEffect
setMessages(prev => {
const last = prev[prev.length - 1];
if (last.role === 'assistant') {
// Parse metadata from raw accumulated content
const rawContent = rawContentRef.current;
const { content, metadata } = parseMetadata(rawContent);
// Fallback: if content is empty after parse but we had content before,
// keep the stripped version (without metadata tag) to preserve user-visible text
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
// Clear ref for next message
rawContentRef.current = '';
return [
...prev.slice(0, -1),
{ ...last, content: finalContent, metadata }
];
}
return prev;
});
// Set loading to false AFTER processing metadata
setIsLoading(false);
break; // Exit inner loop when done
}
} catch (parseError) {
// Log apenas em desenvolvimento para debug, não quebrar o fluxo
if (process.env.NODE_ENV === 'development') {
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
if (!errorMessage.includes('Unexpected end') && !errorMessage.includes('Unexpected token')) {
console.warn('Failed to parse JSON chunk:', errorMessage);
}
}
// Continuar processamento mesmo se uma linha falhar
continue;
}
}
}
// If stream finished without explicit json.done, process metadata anyway
if (streamFinished && !metadataProcessed && rawContentRef.current) {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last && last.role === 'assistant') {
const rawContent = rawContentRef.current;
const { content, metadata } = parseMetadata(rawContent);
const finalContent = removeMetadataNoise(content || last.content || stripMetadataTag(rawContent));
rawContentRef.current = '';
return [
...prev.slice(0, -1),
{ ...last, content: finalContent, metadata }
];
}
return prev;
});
setIsLoading(false);
} else if (streamFinished && !metadataProcessed) {
// Stream finished but no content to process, just set loading to false
setIsLoading(false);
}
};
// Tentar enviar com retry logic
let lastError: any = null;
const maxRetries = 2; // Máximo de 2 retries (3 tentativas no total)
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await attemptSend();
return; // Sucesso, sair do loop
} catch (error: any) {
lastError = error;
// Não fazer retry para certos erros
if (error.message === 'ModelNotFound' ||
error.message === 'OllamaNotRunning' ||
error.message === 'ConnectionError' ||
error.name === 'AbortError' ||
attempt === maxRetries) { // Última tentativa
break;
}
// Aguardar antes de tentar novamente (backoff exponencial)
if (attempt < maxRetries) {
const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
console.log(`Tentativa ${attempt + 1} falhou, tentando novamente em ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Se chegou aqui, todas as tentativas falharam
throw lastError;
} 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;
+524
View File
@@ -0,0 +1,524 @@
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';
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[];
}
// ========== 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;
}
};
// --- 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 {
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 || []
};
}
+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,
};
}
+56
View File
@@ -0,0 +1,56 @@
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 };
}
+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,
};
}