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:
1 parent
4275295e4f
commit
84ea2dec19
51 files changed
+7857
-573
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 }))
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -7,28 +7,37 @@ export function useOllamaCheck() {
|
||||
const [status, setStatus] = useState<OllamaStatus>('checking');
|
||||
|
||||
const check = async () => {
|
||||
// Don't set checking here if we want to silently revalidate,
|
||||
// but for initial load or explicit re-check it is fine.
|
||||
// Only set checking if it's not already in a known state to avoid flicker?
|
||||
// For now simple is better.
|
||||
setStatus('checking');
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_ollama_installed');
|
||||
if (!installed) {
|
||||
setStatus('not_installed');
|
||||
return;
|
||||
}
|
||||
|
||||
const running = await invoke<boolean>('check_ollama_running');
|
||||
if (!running) {
|
||||
setStatus('installed_stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('running');
|
||||
// Usar verificação completa que checa instalação E execução
|
||||
const result = await invoke<{
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
status: string;
|
||||
}>('check_ollama_full');
|
||||
|
||||
setStatus(result.status as OllamaStatus);
|
||||
} catch (error) {
|
||||
console.error('Failed to check ollama:', error);
|
||||
// If invoke fails (e.g. backend not ready), we might want to handle gracefully
|
||||
setStatus('not_installed');
|
||||
// Se falhar, tentar verificação individual como fallback
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_ollama_installed');
|
||||
if (!installed) {
|
||||
setStatus('not_installed');
|
||||
return;
|
||||
}
|
||||
|
||||
const running = await invoke<boolean>('check_ollama_running');
|
||||
if (!running) {
|
||||
setStatus('installed_stopped');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('running');
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback check also failed:', fallbackError);
|
||||
setStatus('not_installed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { downloadInstaller, getDownloadedInstallerPath, runInstaller, type OS } from '@/lib/download-utils';
|
||||
|
||||
interface DownloadState {
|
||||
isDownloading: boolean;
|
||||
downloadProgress: number;
|
||||
downloadStatus: string;
|
||||
downloadError: string | null;
|
||||
filePath: string | null;
|
||||
isInstalled: boolean;
|
||||
isChecking: boolean;
|
||||
checkStatus: string;
|
||||
}
|
||||
|
||||
export function useOllamaDownload() {
|
||||
const [state, setState] = useState<DownloadState>({
|
||||
isDownloading: false,
|
||||
downloadProgress: 0,
|
||||
downloadStatus: '',
|
||||
downloadError: null,
|
||||
filePath: null,
|
||||
isInstalled: false,
|
||||
isChecking: false,
|
||||
checkStatus: '',
|
||||
});
|
||||
|
||||
const checkingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isCheckingRef = useRef(false);
|
||||
|
||||
// Escutar eventos de progresso do download
|
||||
useEffect(() => {
|
||||
const unlistenPromise = listen('installer-download-progress', (event: any) => {
|
||||
const data = event.payload;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
downloadProgress: data.progress || 0,
|
||||
downloadStatus: data.status || '',
|
||||
}));
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenPromise.then(unlisten => unlisten());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownload = async (os: OS) => {
|
||||
setState({
|
||||
isDownloading: true,
|
||||
downloadProgress: 0,
|
||||
downloadStatus: 'Iniciando download...',
|
||||
downloadError: null,
|
||||
filePath: null,
|
||||
isInstalled: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// Verificar se já foi baixado
|
||||
const existingPath = await getDownloadedInstallerPath(os);
|
||||
if (existingPath) {
|
||||
setState({
|
||||
isDownloading: false,
|
||||
downloadProgress: 100,
|
||||
downloadStatus: 'Download já concluído',
|
||||
downloadError: null,
|
||||
filePath: existingPath,
|
||||
isInstalled: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fazer download
|
||||
const filePath = await downloadInstaller(os);
|
||||
setState({
|
||||
isDownloading: false,
|
||||
downloadProgress: 100,
|
||||
downloadStatus: 'Download concluído',
|
||||
downloadError: null,
|
||||
filePath,
|
||||
isInstalled: false,
|
||||
});
|
||||
} catch (error) {
|
||||
setState({
|
||||
isDownloading: false,
|
||||
downloadProgress: 0,
|
||||
downloadStatus: '',
|
||||
downloadError: error instanceof Error ? error.message : 'Erro desconhecido durante download',
|
||||
filePath: null,
|
||||
isInstalled: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!state.filePath) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
downloadError: 'Nenhum instalador disponível',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runInstaller(state.filePath);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isInstalled: true,
|
||||
}));
|
||||
|
||||
// Iniciar verificação automática em segundo plano
|
||||
startAutoCheck();
|
||||
} catch (error) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
downloadError: error instanceof Error ? error.message : 'Erro ao executar instalador',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const checkOllama = async (): Promise<boolean> => {
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_ollama_installed');
|
||||
if (!installed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const running = await invoke<boolean>('check_ollama_running');
|
||||
return running;
|
||||
} catch (error) {
|
||||
console.error('Erro ao verificar Ollama:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const startAutoCheck = () => {
|
||||
// Limpar intervalo anterior se existir
|
||||
if (checkingIntervalRef.current) {
|
||||
clearInterval(checkingIntervalRef.current);
|
||||
}
|
||||
|
||||
if (isCheckingRef.current) {
|
||||
return; // Já está verificando
|
||||
}
|
||||
|
||||
isCheckingRef.current = true;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isChecking: true,
|
||||
checkStatus: 'Verificando instalação do Ollama...',
|
||||
}));
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 300; // 5 minutos (300 * 1 segundo)
|
||||
|
||||
checkingIntervalRef.current = setInterval(async () => {
|
||||
attempts++;
|
||||
|
||||
const isRunning = await checkOllama();
|
||||
|
||||
if (isRunning) {
|
||||
// Ollama encontrado e rodando!
|
||||
if (checkingIntervalRef.current) {
|
||||
clearInterval(checkingIntervalRef.current);
|
||||
checkingIntervalRef.current = null;
|
||||
}
|
||||
isCheckingRef.current = false;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isChecking: false,
|
||||
checkStatus: 'Ollama encontrado e rodando!',
|
||||
}));
|
||||
|
||||
// Emitir evento customizado para notificar o componente pai
|
||||
window.dispatchEvent(new CustomEvent('ollama-installed'));
|
||||
} else if (attempts >= maxAttempts) {
|
||||
// Limite de tentativas atingido
|
||||
if (checkingIntervalRef.current) {
|
||||
clearInterval(checkingIntervalRef.current);
|
||||
checkingIntervalRef.current = null;
|
||||
}
|
||||
isCheckingRef.current = false;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isChecking: false,
|
||||
checkStatus: 'Verificação automática pausada. Use "Verificar Novamente" para continuar.',
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
checkStatus: `Verificando... (${attempts}/${maxAttempts})`,
|
||||
}));
|
||||
}
|
||||
}, 1000); // Verificar a cada 1 segundo
|
||||
};
|
||||
|
||||
const stopAutoCheck = () => {
|
||||
if (checkingIntervalRef.current) {
|
||||
clearInterval(checkingIntervalRef.current);
|
||||
checkingIntervalRef.current = null;
|
||||
}
|
||||
isCheckingRef.current = false;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isChecking: false,
|
||||
checkStatus: '',
|
||||
}));
|
||||
};
|
||||
|
||||
// Limpar intervalo ao desmontar
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (checkingIntervalRef.current) {
|
||||
clearInterval(checkingIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkExistingDownload = async (os: OS) => {
|
||||
const existingPath = await getDownloadedInstallerPath(os);
|
||||
if (existingPath) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
filePath: existingPath,
|
||||
downloadProgress: 100,
|
||||
downloadStatus: 'Download já concluído',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
stopAutoCheck();
|
||||
setState({
|
||||
isDownloading: false,
|
||||
downloadProgress: 0,
|
||||
downloadStatus: '',
|
||||
downloadError: null,
|
||||
filePath: null,
|
||||
isInstalled: false,
|
||||
isChecking: false,
|
||||
checkStatus: '',
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
handleDownload,
|
||||
handleInstall,
|
||||
checkExistingDownload,
|
||||
startAutoCheck,
|
||||
stopAutoCheck,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user