feat: update dependencies and enhance chat functionality
- Added new Radix UI components including checkbox, label, popover, slider, switch, and tooltip to improve UI interactions. - Integrated react-syntax-highlighter for better code rendering in chat messages. - Updated package.json and pnpm-lock.yaml to include new dependencies and ensure compatibility. - Enhanced chat input to support web search toggling, improving user experience during message composition. - Implemented auto-saving of chat sessions and improved session management features. - Refactored chat message rendering to clean up metadata and improve display quality.
This commit is contained in:
1 parent
032722e21b
commit
8834ef2b4f
55 files changed
+20030
-159
No files matched your search
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
const TARGET_MODEL = "qwen2.5:0.5b";
|
||||
|
||||
export function useAutoLabelingModel() {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [progress, setProgress] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
checkAndDownload();
|
||||
}, []);
|
||||
|
||||
const checkAndDownload = async () => {
|
||||
try {
|
||||
const installed = await invoke<boolean>('check_if_model_installed', { name: TARGET_MODEL });
|
||||
if (installed) {
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloading(true);
|
||||
// Listen for progress
|
||||
const unlisten = await listen<string>('download-progress', (event) => {
|
||||
const payload = typeof event.payload === 'string' ? event.payload : String(event.payload);
|
||||
|
||||
// Try to parse as JSON, if fails use as plain text
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
if (data.total && data.completed) {
|
||||
const percent = Math.round((data.completed / data.total) * 100);
|
||||
setProgress(`Baixando modelo de IA (${percent}%)`);
|
||||
} else if (data.status) {
|
||||
setProgress(data.status);
|
||||
} else {
|
||||
setProgress("Baixando...");
|
||||
}
|
||||
} catch {
|
||||
// Use payload as-is if it's not JSON
|
||||
setProgress(payload || "Baixando...");
|
||||
}
|
||||
});
|
||||
|
||||
await invoke('pull_model', { name: TARGET_MODEL });
|
||||
|
||||
unlisten();
|
||||
setIsDownloading(false);
|
||||
setIsReady(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to setup auto-labeling model:", error);
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { isReady, isDownloading, progress };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Message } from './use-chat';
|
||||
import { removeMetadataNoise } from '@/lib/metadata';
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
emoji: string;
|
||||
updated_at: string;
|
||||
preview: string;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
const TITLE_MODEL = "qwen2.5:0.5b";
|
||||
|
||||
// Helper to generate fallback title from user's first message
|
||||
function getFallbackTitleFromMessages(messages: Message[]): string {
|
||||
// Find the first user message
|
||||
const firstUserMessage = messages.find(msg => msg.role === 'user');
|
||||
|
||||
if (firstUserMessage && firstUserMessage.content) {
|
||||
// Clean the content (remove metadata tags if any)
|
||||
let content = firstUserMessage.content.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||
|
||||
// Truncate to 50 characters
|
||||
if (content.length > 50) {
|
||||
content = content.substring(0, 50).trim() + '...';
|
||||
}
|
||||
|
||||
return content || "Nova Conversa";
|
||||
}
|
||||
|
||||
return "Nova Conversa";
|
||||
}
|
||||
|
||||
export function useChatStorage() {
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [isGeneratingTitle, setIsGeneratingTitle] = useState(false);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const list = await invoke<SessionSummary[]>('load_chat_sessions');
|
||||
setSessions(list);
|
||||
} catch (error) {
|
||||
console.error("Failed to load sessions:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSessionHistory = useCallback(async (id: string): Promise<Message[]> => {
|
||||
try {
|
||||
const rawMessages = await invoke<any[]>('load_chat_history', { id });
|
||||
// Convert role from String to type-safe union and ensure proper types
|
||||
return rawMessages.map(msg => ({
|
||||
role: (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'system')
|
||||
? msg.role as 'user' | 'assistant' | 'system'
|
||||
: 'user' as const, // fallback
|
||||
content: msg.content || '',
|
||||
metadata: msg.metadata || undefined
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to load history:", error);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteSession = useCallback(async (id: string) => {
|
||||
try {
|
||||
await invoke('delete_chat_session', { id });
|
||||
await loadSessions();
|
||||
if (currentSessionId === id) {
|
||||
setCurrentSessionId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error);
|
||||
}
|
||||
}, [currentSessionId, loadSessions]);
|
||||
|
||||
const generateTitleFromUserMessage = async (userMessage: string): Promise<string> => {
|
||||
// Generate title immediately from user's first message
|
||||
try {
|
||||
const response = await fetch('http://localhost:11434/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: TITLE_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Generate a short title (3-5 words) for a chat conversation based on this user message. Output ONLY the title, no explanation.' },
|
||||
{ role: 'user', content: userMessage }
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If model not available, use fallback
|
||||
if (response.status === 404 || response.status >= 500) {
|
||||
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
|
||||
}
|
||||
throw new Error(`Title generation failed with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
let title = data.message?.content?.trim() || "";
|
||||
|
||||
// If empty from start, use fallback
|
||||
if (!title || title.length === 0) {
|
||||
throw new Error("Empty title returned from model");
|
||||
}
|
||||
|
||||
// Remove quotes if present (both single and double)
|
||||
title = title.replace(/^["']+|["']+$/g, '').trim();
|
||||
|
||||
// Remove any metadata tags that might have leaked through
|
||||
title = title.replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||
|
||||
// Check if title is empty or contains only whitespace/punctuation
|
||||
if (!title || title.length === 0 || /^[\s\W]*$/.test(title)) {
|
||||
throw new Error("Title became empty or invalid after cleaning");
|
||||
}
|
||||
|
||||
// Limit title length to reasonable size (max 60 chars for safety)
|
||||
if (title.length > 60) {
|
||||
title = title.substring(0, 60).trim() + '...';
|
||||
}
|
||||
|
||||
return title;
|
||||
} catch (error) {
|
||||
console.error("Title generation failed:", error);
|
||||
// Use user message as fallback (truncated)
|
||||
let fallback = userMessage.trim();
|
||||
if (fallback.length > 50) {
|
||||
fallback = fallback.substring(0, 50).trim() + '...';
|
||||
}
|
||||
return fallback || "Nova Conversa";
|
||||
}
|
||||
};
|
||||
|
||||
const generateTitle = async (messages: Message[]): Promise<string> => {
|
||||
// Only generate if we have enough context (e.g., 2 messages: user + assistant)
|
||||
// and use a lightweight model
|
||||
try {
|
||||
const sanitizedMessages = messages.slice(0, 4).map(m => ({
|
||||
...m,
|
||||
content: removeMetadataNoise(m.content),
|
||||
}));
|
||||
|
||||
const response = await fetch('http://localhost:11434/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: TITLE_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Summarize the following conversation in 3 to 5 words representing a short title. Output ONLY the title, sem metadados.' },
|
||||
...sanitizedMessages
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// If model not found or unavailable, throw to trigger fallback
|
||||
if (response.status === 404 || response.status >= 500) {
|
||||
throw new Error(`Model ${TITLE_MODEL} not available (status: ${response.status})`);
|
||||
}
|
||||
// For other errors, use fallback
|
||||
throw new Error(`Title generation failed with status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
let title = removeMetadataNoise(data.message?.content?.trim() || "");
|
||||
|
||||
// If empty from start, throw to trigger fallback
|
||||
if (!title || title.length === 0) {
|
||||
throw new Error("Empty title returned from model");
|
||||
}
|
||||
|
||||
// Remove quotes if present (both single and double)
|
||||
title = title.replace(/^["']+|["']+$/g, '').trim();
|
||||
|
||||
// Remove any metadata tags that might have leaked through
|
||||
title = removeMetadataNoise(title);
|
||||
|
||||
// Check if title is empty or contains only whitespace/punctuation
|
||||
if (!title || title.length === 0 || /^[\s\W]*$/.test(title)) {
|
||||
throw new Error("Title became empty or invalid after cleaning");
|
||||
}
|
||||
|
||||
// Limit title length to reasonable size (max 60 chars for safety)
|
||||
if (title.length > 60) {
|
||||
title = title.substring(0, 60).trim() + '...';
|
||||
}
|
||||
|
||||
return title;
|
||||
} catch (error) {
|
||||
console.error("Title generation failed:", error);
|
||||
// Don't return "Nova Conversa" here - let the caller use the fallback
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const saveSession = useCallback(async (id: string, messages: Message[], currentTitle?: string) => {
|
||||
if (messages.length === 0) return;
|
||||
|
||||
// Check if session already exists and has a valid title
|
||||
const existingSession = sessions.find(s => s.id === id);
|
||||
const existingTitle = existingSession?.title;
|
||||
|
||||
// If session already exists with a valid title, always update it
|
||||
if (existingSession && existingTitle && existingTitle !== "Nova Conversa") {
|
||||
const title = currentTitle || existingTitle;
|
||||
|
||||
// Extract memory context from metadata keywords
|
||||
const memoryContext: string[] = [];
|
||||
messages.forEach(msg => {
|
||||
if (msg.role === 'assistant' && msg.metadata?.keywords) {
|
||||
const keywords = Array.isArray(msg.metadata.keywords)
|
||||
? msg.metadata.keywords
|
||||
: [];
|
||||
memoryContext.push(...keywords.filter(k => typeof k === 'string'));
|
||||
}
|
||||
});
|
||||
const uniqueMemoryContext = Array.from(new Set(memoryContext));
|
||||
|
||||
const platform = typeof navigator !== 'undefined'
|
||||
? navigator.platform || navigator.userAgent.split(' ')[0] || 'Unknown'
|
||||
: 'Unknown';
|
||||
|
||||
try {
|
||||
await invoke('save_chat_session', {
|
||||
id,
|
||||
title,
|
||||
messages,
|
||||
platform: platform,
|
||||
memory_context: uniqueMemoryContext
|
||||
});
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to save session:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For new sessions, only save after title is generated
|
||||
// Check if we have at least user + assistant (first complete response)
|
||||
if (messages.length < 2) {
|
||||
// Don't save yet - wait for first AI response
|
||||
return;
|
||||
}
|
||||
|
||||
let title: string | null = currentTitle || null;
|
||||
let shouldGenerateTitle = !currentTitle;
|
||||
|
||||
// Check for metadata title in assistant messages (prioritize title over summary)
|
||||
if (shouldGenerateTitle) {
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'assistant' && msg.metadata) {
|
||||
// Prioritize title, fallback to summary
|
||||
if (msg.metadata.title) {
|
||||
const titleStr = msg.metadata.title.trim();
|
||||
if (titleStr && titleStr !== "") {
|
||||
title = titleStr;
|
||||
shouldGenerateTitle = false;
|
||||
break;
|
||||
}
|
||||
} else if (msg.metadata.summary) {
|
||||
const summary = msg.metadata.summary.trim();
|
||||
if (summary && summary !== "") {
|
||||
title = summary;
|
||||
shouldGenerateTitle = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract memory context from metadata keywords
|
||||
const memoryContext: string[] = [];
|
||||
messages.forEach(msg => {
|
||||
if (msg.role === 'assistant' && msg.metadata?.keywords) {
|
||||
const keywords = Array.isArray(msg.metadata.keywords)
|
||||
? msg.metadata.keywords
|
||||
: [];
|
||||
memoryContext.push(...keywords.filter(k => typeof k === 'string'));
|
||||
}
|
||||
});
|
||||
// Deduplicate
|
||||
const uniqueMemoryContext = Array.from(new Set(memoryContext));
|
||||
|
||||
// Detect platform
|
||||
const platform = typeof navigator !== 'undefined'
|
||||
? navigator.platform || navigator.userAgent.split(' ')[0] || 'Unknown'
|
||||
: 'Unknown';
|
||||
|
||||
// If we have title from metadata, save immediately
|
||||
if (title && !shouldGenerateTitle) {
|
||||
try {
|
||||
await invoke('save_chat_session', {
|
||||
id,
|
||||
title,
|
||||
messages,
|
||||
platform: platform,
|
||||
memory_context: uniqueMemoryContext
|
||||
});
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to save session:", error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-generate title using separate model - only create card when title is ready
|
||||
if (shouldGenerateTitle && !isGeneratingTitle) {
|
||||
setIsGeneratingTitle(true);
|
||||
generateTitle(messages)
|
||||
.then(async (newTitle) => {
|
||||
setIsGeneratingTitle(false);
|
||||
// Now create the card with the generated title
|
||||
try {
|
||||
await invoke('save_chat_session', {
|
||||
id,
|
||||
title: newTitle,
|
||||
messages,
|
||||
platform: platform,
|
||||
memory_context: uniqueMemoryContext
|
||||
});
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
console.error("Failed to save session with generated title:", e);
|
||||
setIsGeneratingTitle(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setIsGeneratingTitle(false);
|
||||
// On error, use fallback title from user's first message
|
||||
// Only log if it's not an expected error (empty title, model unavailable)
|
||||
const isExpectedError = error.message?.includes("Empty title") ||
|
||||
error.message?.includes("not available") ||
|
||||
error.message?.includes("invalid after cleaning");
|
||||
|
||||
if (!isExpectedError) {
|
||||
console.error("Title generation error:", error);
|
||||
} else {
|
||||
console.log(`Title generation failed (expected): ${error.message}`);
|
||||
}
|
||||
|
||||
const fallbackTitle = getFallbackTitleFromMessages(messages);
|
||||
console.log(`Using fallback title from user message: "${fallbackTitle}"`);
|
||||
|
||||
invoke('save_chat_session', {
|
||||
id,
|
||||
title: fallbackTitle,
|
||||
messages,
|
||||
platform: platform,
|
||||
memory_context: uniqueMemoryContext
|
||||
}).then(() => loadSessions()).catch(e => console.error("Failed to save session with fallback:", e));
|
||||
});
|
||||
}
|
||||
}, [loadSessions, isGeneratingTitle, sessions, generateTitle]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
setCurrentSessionId,
|
||||
loadSessions,
|
||||
loadSessionHistory,
|
||||
saveSession,
|
||||
deleteSession,
|
||||
isGeneratingTitle,
|
||||
setIsGeneratingTitle,
|
||||
generateTitleFromUserMessage
|
||||
};
|
||||
}
|
||||
|
||||
+315
-7
@@ -1,16 +1,209 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { removeMetadataNoise } from '@/lib/metadata';
|
||||
import { useSettingsStore } from '@/store/settings-store';
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
interface ToolCall {
|
||||
server_name: string;
|
||||
tool_name: string;
|
||||
arguments: any;
|
||||
}
|
||||
|
||||
function parseMetadata(fullContent: string): { content: string, metadata?: any } {
|
||||
if (!fullContent || fullContent.trim() === '') {
|
||||
return { content: fullContent || '' };
|
||||
}
|
||||
|
||||
// More flexible regex that handles whitespace and line breaks (case-insensitive)
|
||||
const metadataRegex = /<metadata>\s*([\s\S]*?)\s*<\/metadata>/gi;
|
||||
|
||||
// First, extract all metadata blocks
|
||||
const metadataMatches: Array<{ match: string, json: any }> = [];
|
||||
let match;
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = metadataRegex.exec(fullContent)) !== null) {
|
||||
const metadataStr = match[1].trim();
|
||||
try {
|
||||
const json = JSON.parse(metadataStr);
|
||||
metadataMatches.push({ match: match[0], json });
|
||||
} catch (e) {
|
||||
// Invalid JSON, just remove the tag
|
||||
console.error("Failed to parse metadata JSON", e, "Raw:", metadataStr);
|
||||
}
|
||||
lastIndex = metadataRegex.lastIndex;
|
||||
}
|
||||
|
||||
// Remove ALL metadata blocks from content
|
||||
let content = fullContent.replace(/<metadata>\s*[\s\S]*?\s*<\/metadata>/gi, '').trim();
|
||||
content = removeMetadataNoise(content);
|
||||
|
||||
// Also remove common prefixes if they appear at the end of the content
|
||||
const prefixes = [
|
||||
'Bloco oculto de metadados:',
|
||||
'Metadata:',
|
||||
'Metadados:',
|
||||
'Hidden metadata block:',
|
||||
'JSON metadata:',
|
||||
'---'
|
||||
];
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we don't return empty content if there was content before metadata
|
||||
if (!content && fullContent.length > 0) {
|
||||
// If all content was metadata, keep nothing (shouldn't happen but safe fallback)
|
||||
content = '';
|
||||
}
|
||||
|
||||
// Return first valid metadata (if any)
|
||||
const metadata = metadataMatches.length > 0 ? metadataMatches[0].json : undefined;
|
||||
|
||||
return { content, metadata };
|
||||
}
|
||||
|
||||
// Helper to strip metadata in real-time during streaming (for display)
|
||||
function stripMetadataTag(content: string): string {
|
||||
if (!content) return '';
|
||||
|
||||
// Common prefixes that models might output before the metadata block
|
||||
const prefixes = [
|
||||
'Bloco oculto de metadados:',
|
||||
'Metadata:',
|
||||
'Metadados:',
|
||||
'Hidden metadata block:',
|
||||
'JSON metadata:',
|
||||
'---'
|
||||
];
|
||||
|
||||
let cleaned = content;
|
||||
|
||||
// Remove any metadata tags (complete or incomplete) for real-time display
|
||||
// Case-insensitive regex to catch variations
|
||||
cleaned = cleaned.replace(/<metadata>[\s\S]*?$/gi, '').replace(/<metadata>[\s\S]*?<\/metadata>/gi, '').trim();
|
||||
|
||||
// Aggressively strip known prefixes if they appear at the very end of the content
|
||||
// This helps hide "Bloco oculto de metadados:" while the tag is being generated
|
||||
for (const prefix of prefixes) {
|
||||
const regex = new RegExp(`${prefix}\\s*$`, 'i');
|
||||
if (regex.test(cleaned)) {
|
||||
cleaned = cleaned.replace(regex, '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
return removeMetadataNoise(cleaned);
|
||||
}
|
||||
|
||||
// Helper to detect tool calls in LLM response
|
||||
export function detectToolCalls(content: string, availableTools: any[]): ToolCall[] {
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
// Simple pattern matching for tool calls
|
||||
// Pattern: "use tool X with args Y" or "execute X(args)"
|
||||
// This is a basic implementation - can be enhanced with better NLP
|
||||
|
||||
// Look for common patterns
|
||||
const patterns = [
|
||||
// Pattern 1: "use [tool_name] with [args]" or "use [tool_name] (args)"
|
||||
/(?:use|call|execute|run)\s+([a-z_]+)\s+(?:with|\(|\[)\s*([^\n)]+)/gi,
|
||||
// Pattern 2: "[tool_name](args)"
|
||||
/([a-z_]+)\s*\(([^)]+)\)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
const potentialToolName = match[1].toLowerCase().trim();
|
||||
const potentialArgs = match[2]?.trim() || '{}';
|
||||
|
||||
// Find matching tool in available tools
|
||||
for (const toolInfo of availableTools) {
|
||||
if (toolInfo.tool.name.toLowerCase() === potentialToolName) {
|
||||
// Try to parse arguments
|
||||
let args: any = {};
|
||||
try {
|
||||
// Try JSON first
|
||||
args = JSON.parse(potentialArgs);
|
||||
} catch {
|
||||
// If not JSON, try to parse key-value pairs
|
||||
try {
|
||||
// Simple key=value parsing
|
||||
const pairs = potentialArgs.split(',').map(p => p.trim());
|
||||
args = {};
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=').map(s => s.trim());
|
||||
if (key && value) {
|
||||
args[key] = value.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If all fails, use empty object
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls.push({
|
||||
server_name: toolInfo.server_name,
|
||||
tool_name: toolInfo.tool.name,
|
||||
arguments: args,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toolCalls;
|
||||
}
|
||||
|
||||
// Helper to execute a tool call
|
||||
export async function executeToolCall(toolCall: ToolCall): Promise<string> {
|
||||
try {
|
||||
const result = await invoke<any>('call_mcp_tool', {
|
||||
serverName: toolCall.server_name,
|
||||
toolName: toolCall.tool_name,
|
||||
arguments: toolCall.arguments,
|
||||
});
|
||||
|
||||
// Format result for display
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
} else if (result.content) {
|
||||
return result.content;
|
||||
} else {
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Tool execution error:', error);
|
||||
return `Error executing tool ${toolCall.tool_name}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function useChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const rawContentRef = useRef<string>(''); // Track raw content including metadata during streaming
|
||||
|
||||
const sendMessage = async (content: string, model: string, systemPrompt?: string) => {
|
||||
const sendMessage = async (
|
||||
content: string,
|
||||
model: string,
|
||||
systemPrompt?: string,
|
||||
options?: {
|
||||
onBeforeModelRequest?: (pendingMessages: Message[]) => Promise<void>;
|
||||
}
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
|
||||
const newMessages: Message[] = [
|
||||
@@ -20,9 +213,56 @@ export function useChat() {
|
||||
setMessages(newMessages);
|
||||
|
||||
// Prepare messages for API (include system prompt if present)
|
||||
const apiMessages = systemPrompt
|
||||
? [{ role: 'system', content: systemPrompt }, ...newMessages]
|
||||
: newMessages;
|
||||
// Strip metadata from previous messages before sending to context to save tokens?
|
||||
// Or keep it? The model might benefit from seeing previous metadata.
|
||||
// Let's keep it for now, but we need to make sure we send what the API expects.
|
||||
// Ollama API expects { role, content }. It ignores extra fields usually, but let's be safe.
|
||||
if (options?.onBeforeModelRequest) {
|
||||
await options.onBeforeModelRequest(newMessages);
|
||||
}
|
||||
|
||||
// Aplicar janela deslizante para limitar contexto
|
||||
const settings = useSettingsStore.getState();
|
||||
const maxTokens = settings.contextWindow || 4096;
|
||||
const maxMessages = 10; // Máximo de mensagens a manter
|
||||
|
||||
// Estimar tokens (aproximação: 1 token ≈ 4 caracteres)
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
// Aplicar janela deslizante: manter últimas N mensagens que cabem no limite de tokens
|
||||
let truncatedMessages = [...newMessages];
|
||||
let totalTokens = systemPrompt ? estimateTokens(systemPrompt) : 0;
|
||||
|
||||
// Sempre manter a última mensagem do usuário
|
||||
const lastUserMessage = truncatedMessages[truncatedMessages.length - 1];
|
||||
truncatedMessages = truncatedMessages.slice(0, -1);
|
||||
|
||||
// Adicionar mensagens de trás para frente até atingir o limite
|
||||
const selectedMessages: Message[] = [];
|
||||
for (let i = truncatedMessages.length - 1; i >= 0; i--) {
|
||||
const msg = truncatedMessages[i];
|
||||
const msgTokens = estimateTokens(msg.content);
|
||||
|
||||
// Limitar por número de mensagens E por tokens
|
||||
if (selectedMessages.length >= maxMessages - 1) break; // -1 porque vamos adicionar a última depois
|
||||
if (totalTokens + msgTokens > maxTokens * 0.9) break; // 90% do limite para margem de segurança
|
||||
|
||||
selectedMessages.unshift(msg);
|
||||
totalTokens += msgTokens;
|
||||
}
|
||||
|
||||
// Adicionar a última mensagem do usuário
|
||||
selectedMessages.push(lastUserMessage);
|
||||
totalTokens += estimateTokens(lastUserMessage.content);
|
||||
|
||||
const apiMessages = (systemPrompt
|
||||
? [{ role: 'system', content: systemPrompt }, ...selectedMessages]
|
||||
: selectedMessages).map(m => ({
|
||||
role: m.role,
|
||||
content: m.content // Send only content, not metadata (unless we want to inject it back)
|
||||
}));
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
@@ -44,12 +284,21 @@ export function useChat() {
|
||||
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) break;
|
||||
if (done) {
|
||||
streamFinished = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
||||
@@ -58,25 +307,83 @@ export function useChat() {
|
||||
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: last.content + json.message.content }
|
||||
{ ...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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('Request aborted');
|
||||
@@ -99,8 +406,9 @@ export function useChat() {
|
||||
|
||||
const clearChat = () => {
|
||||
setMessages([]);
|
||||
rawContentRef.current = '';
|
||||
};
|
||||
|
||||
return { messages, sendMessage, isLoading, stop, clearChat };
|
||||
return { messages, setMessages, sendMessage, isLoading, stop, clearChat };
|
||||
}
|
||||
|
||||
@@ -41,3 +41,5 @@ export function useLocalModels() {
|
||||
return { models, loading, refresh: fetchModels, deleteModel };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface McpServerInfo {
|
||||
name: string;
|
||||
status: 'running' | 'stopped' | 'error';
|
||||
pid?: number;
|
||||
toolCount?: number;
|
||||
}
|
||||
|
||||
export function useMcpServers() {
|
||||
const [servers, setServers] = useState<McpServerInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadServers = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const serverStatuses = await invoke<McpServerInfo[]>('list_mcp_server_status');
|
||||
|
||||
// Get tool counts for each server
|
||||
const allTools = await invoke<Array<{ server_name: string; tool: any }>>('get_all_mcp_tools');
|
||||
|
||||
// Count tools per server
|
||||
const toolCounts = new Map<string, number>();
|
||||
for (const toolInfo of allTools) {
|
||||
toolCounts.set(toolInfo.server_name, (toolCounts.get(toolInfo.server_name) || 0) + 1);
|
||||
}
|
||||
|
||||
// Enrich server info with tool counts
|
||||
const enrichedServers = serverStatuses.map(server => ({
|
||||
...server,
|
||||
toolCount: toolCounts.get(server.name) || 0,
|
||||
}));
|
||||
|
||||
setServers(enrichedServers);
|
||||
} catch (err) {
|
||||
console.error('Failed to load MCP servers:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load MCP servers');
|
||||
setServers([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
|
||||
// Refresh servers every 10 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadServers();
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadServers]);
|
||||
|
||||
// Get only running servers
|
||||
const getRunningServers = useCallback((): McpServerInfo[] => {
|
||||
return servers.filter(server => server.status === 'running');
|
||||
}, [servers]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
runningServers: getRunningServers(),
|
||||
isLoading,
|
||||
error,
|
||||
loadServers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema?: any;
|
||||
}
|
||||
|
||||
export interface McpToolInfo {
|
||||
server_name: string;
|
||||
tool: McpTool;
|
||||
}
|
||||
|
||||
export function useMcpTools() {
|
||||
const [tools, setTools] = useState<McpToolInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTools = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const allTools = await invoke<McpToolInfo[]>('get_all_mcp_tools');
|
||||
setTools(allTools);
|
||||
} catch (err) {
|
||||
console.error('Failed to load MCP tools:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load MCP tools');
|
||||
setTools([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTools();
|
||||
|
||||
// Refresh tools every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadTools();
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [loadTools]);
|
||||
|
||||
// Generate tools context for prompt injection
|
||||
const generateToolsContext = useCallback((availableTools: McpToolInfo[], selectedServers?: string[]): string => {
|
||||
// Filter tools by selected servers if provided
|
||||
let filteredTools = availableTools;
|
||||
if (selectedServers && selectedServers.length > 0) {
|
||||
filteredTools = availableTools.filter(tool => selectedServers.includes(tool.server_name));
|
||||
}
|
||||
|
||||
if (filteredTools.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let context = '\n\n## Ferramentas MCP Disponíveis\n\n';
|
||||
const header = selectedServers && selectedServers.length > 0
|
||||
? `Você está usando os servidores MCP selecionados: ${selectedServers.join(', ')}.\n`
|
||||
: 'Você tem acesso aos servidores MCP listados abaixo.\n';
|
||||
context += `${header}\n`;
|
||||
context += '### REGRAS OBRIGATÓRIAS PARA MCP:\n\n';
|
||||
context += '1. **PRIORIZE RESULTADOS DE MCP**: Se você vir mensagens anteriores marcadas com `[MCP servidor/ferramenta]`, USE-AS como base da sua resposta.\n';
|
||||
context += '2. **IDENTIFIQUE FONTES**: Quando há dados de MCP disponíveis, cite-os explicitamente (ex: "De acordo com a busca realizada...").\n';
|
||||
context += '3. **TRATE ERROS**: Se MCP retornou erro (mensagens com ⚠️), informe ao usuário sobre a limitação de forma clara.\n';
|
||||
context += '4. **SOLICITE FERRAMENTAS**: Use o formato `<tool_name>(arg1=valor1, arg2=valor2)` para solicitar execução de ferramentas.\n';
|
||||
context += '5. **PROIBIDO**: NUNCA gere blocos `<metadata>`, JSON oculto ou texto como "Metadados:". Apenas texto visível.\n\n';
|
||||
context += '**Nota**: O sistema executa MCPs automaticamente antes de você receber a mensagem do usuário. Verifique sempre se há resultados MCP nas mensagens anteriores.\n\n';
|
||||
|
||||
// Group tools by server
|
||||
const toolsByServer = new Map<string, McpTool[]>();
|
||||
for (const toolInfo of filteredTools) {
|
||||
if (!toolsByServer.has(toolInfo.server_name)) {
|
||||
toolsByServer.set(toolInfo.server_name, []);
|
||||
}
|
||||
toolsByServer.get(toolInfo.server_name)!.push(toolInfo.tool);
|
||||
}
|
||||
|
||||
// Format tools for each server
|
||||
for (const [serverName, serverTools] of toolsByServer.entries()) {
|
||||
context += `### Servidor: ${serverName}\n\n`;
|
||||
|
||||
for (const tool of serverTools) {
|
||||
context += `- **${tool.name}**: ${tool.description}\n`;
|
||||
|
||||
// Add input schema info if available
|
||||
if (tool.input_schema) {
|
||||
const schema = tool.input_schema;
|
||||
if (schema.properties) {
|
||||
context += ` - Parâmetros: `;
|
||||
const params: string[] = [];
|
||||
for (const [key, value] of Object.entries(schema.properties)) {
|
||||
const prop = value as any;
|
||||
params.push(`${key} (${prop.type || 'any'})`);
|
||||
}
|
||||
context += params.join(', ') + '\n';
|
||||
}
|
||||
}
|
||||
context += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
context += '### Como responder quando há dados MCP:\n\n';
|
||||
context += '1. Verifique se há mensagens com `[MCP ...]` no histórico recente\n';
|
||||
context += '2. Analise os dados retornados (mesmo que sejam erros)\n';
|
||||
context += '3. Baseie sua resposta PRINCIPALMENTE nos dados MCP\n';
|
||||
context += '4. Complemente com seu conhecimento apenas se necessário\n';
|
||||
context += '5. Apresente tudo em texto limpo, sem metadados ou JSON oculto\n\n';
|
||||
context += '**Lembre-se**: Resultados MCP são dados em tempo real e devem ter prioridade sobre seu conhecimento interno.\n';
|
||||
|
||||
return context;
|
||||
}, []);
|
||||
|
||||
// Get tools context string for prompt injection
|
||||
const getToolsContext = useCallback((selectedServers?: string[]): string => {
|
||||
return generateToolsContext(tools, selectedServers);
|
||||
}, [tools, generateToolsContext]);
|
||||
|
||||
return {
|
||||
tools,
|
||||
isLoading,
|
||||
error,
|
||||
loadTools,
|
||||
generateToolsContext,
|
||||
getToolsContext,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -68,3 +68,5 @@ export function usePromptGenerator() {
|
||||
return { generatePrompt, isGenerating };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook para gerar query de busca otimizada usando a LLM
|
||||
*/
|
||||
export function useQueryGenerator() {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
/**
|
||||
* Gera uma query de busca otimizada baseada na pergunta do usuário
|
||||
* Retorna 'NO_SEARCH' se não precisar de busca
|
||||
*/
|
||||
const generateQuery = useCallback(async (
|
||||
userInput: string,
|
||||
model: string
|
||||
): Promise<string> => {
|
||||
if (!userInput || !userInput.trim()) {
|
||||
return 'NO_SEARCH';
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
try {
|
||||
// Prompt otimizado para gerar query
|
||||
const prompt = `Baseado na pergunta do usuário abaixo, gere APENAS uma query de busca otimizada para o Google/DuckDuckGo.
|
||||
|
||||
Regras:
|
||||
- Se a pergunta NÃO precisar de informações da web (ex: "Olá", "Como você está?", cálculos simples, perguntas sobre você), retorne EXATAMENTE: NO_SEARCH
|
||||
- Se precisar de informações recentes, fatos, notícias, ou dados atualizados, gere uma query curta e direta (máximo 8 palavras)
|
||||
- A query deve ser em português se a pergunta for em português
|
||||
|
||||
Pergunta do usuário: "${userInput}"
|
||||
|
||||
Query de busca (ou NO_SEARCH):`;
|
||||
|
||||
const response = await fetch('http://localhost:11434/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: 0.3, // Baixa temperatura para respostas mais determinísticas
|
||||
num_predict: 50, // Limitar tokens para resposta rápida
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Falha ao gerar query');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const generatedText = (data.response || '').trim();
|
||||
|
||||
// Limpar resposta (remover aspas, pontos finais, etc)
|
||||
let query = generatedText
|
||||
.replace(/^["']|["']$/g, '') // Remover aspas
|
||||
.replace(/\.$/, '') // Remover ponto final
|
||||
.trim();
|
||||
|
||||
// Verificar se é NO_SEARCH
|
||||
if (query.toUpperCase().includes('NO_SEARCH') || query.length < 3) {
|
||||
return 'NO_SEARCH';
|
||||
}
|
||||
|
||||
return query;
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar query:', error);
|
||||
// Em caso de erro, tentar usar a própria pergunta como query
|
||||
// Mas apenas se parecer uma pergunta factual
|
||||
const lowerInput = userInput.toLowerCase();
|
||||
const needsSearch = lowerInput.includes('o que') ||
|
||||
lowerInput.includes('quem') ||
|
||||
lowerInput.includes('quando') ||
|
||||
lowerInput.includes('onde') ||
|
||||
lowerInput.includes('como') ||
|
||||
lowerInput.includes('por que') ||
|
||||
lowerInput.includes('qual') ||
|
||||
lowerInput.includes('quais');
|
||||
|
||||
return needsSearch ? userInput : 'NO_SEARCH';
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { generateQuery, isGenerating };
|
||||
}
|
||||
|
||||
@@ -27,3 +27,5 @@ export function useSystemMonitor() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface Toast {
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const showToast = useCallback(({ title, description, variant }: Toast) => {
|
||||
// Simple implementation using console and alert for now
|
||||
// Can be enhanced later with a proper toast component
|
||||
if (variant === 'destructive') {
|
||||
console.error(`[Toast] ${title}: ${description || ''}`);
|
||||
alert(`Erro: ${title}\n${description || ''}`);
|
||||
} else {
|
||||
console.log(`[Toast] ${title}: ${description || ''}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
toast: showToast,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { webSearchService, ScrapedContent, SearchConfig } from '@/services/webSearch';
|
||||
import { useSettingsStore } from '@/store/settings-store';
|
||||
|
||||
export type SearchStatus = 'idle' | 'searching' | 'scraping' | 'completed' | 'error';
|
||||
|
||||
export interface WebSearchState {
|
||||
isEnabled: boolean;
|
||||
status: SearchStatus;
|
||||
currentQuery: string;
|
||||
scrapedSources: ScrapedContent[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: WebSearchState = {
|
||||
isEnabled: true,
|
||||
status: 'idle',
|
||||
currentQuery: '',
|
||||
scrapedSources: [],
|
||||
error: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook para gerenciar estado do Web Search
|
||||
*/
|
||||
export function useWebSearch() {
|
||||
const [state, setState] = useState<WebSearchState>(() => {
|
||||
// Carregar preferência do localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('webSearchEnabled');
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
isEnabled: saved !== null ? saved === 'true' : true,
|
||||
};
|
||||
}
|
||||
return DEFAULT_STATE;
|
||||
});
|
||||
|
||||
/**
|
||||
* Ativa/desativa Web Search
|
||||
*/
|
||||
const setEnabled = useCallback((enabled: boolean) => {
|
||||
setState(prev => ({ ...prev, isEnabled: enabled }));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('webSearchEnabled', String(enabled));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Executa busca
|
||||
*/
|
||||
const search = useCallback(async (query: string, limit: number = 3, excludedDomains: string[] = []): Promise<ScrapedContent[]> => {
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'searching',
|
||||
currentQuery: query,
|
||||
error: null,
|
||||
scrapedSources: [],
|
||||
}));
|
||||
|
||||
try {
|
||||
// Buscar configurações do store
|
||||
const settings = useSettingsStore.getState();
|
||||
|
||||
// Construir SearchConfig a partir das configurações
|
||||
const searchConfig: SearchConfig = {
|
||||
maxConcurrentTabs: settings.webSearch.maxConcurrentTabs,
|
||||
totalSourcesLimit: settings.webSearch.totalSourcesLimit,
|
||||
categories: settings.webSearch.categories,
|
||||
userCustomSites: settings.webSearch.userCustomSites,
|
||||
excludedDomains: excludedDomains.length > 0 ? excludedDomains : settings.webSearch.excludedDomains,
|
||||
};
|
||||
|
||||
// Fase 1: Buscando
|
||||
setState(prev => ({ ...prev, status: 'searching' }));
|
||||
|
||||
// Fase 2: Scraping (o serviço já faz isso internamente)
|
||||
setState(prev => ({ ...prev, status: 'scraping' }));
|
||||
|
||||
const results = await webSearchService.search(query, limit, excludedDomains, searchConfig);
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'completed',
|
||||
scrapedSources: results,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido ao buscar';
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
scrapedSources: [],
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Extrai conteúdo de uma URL
|
||||
*/
|
||||
const extractUrl = useCallback(async (url: string): Promise<ScrapedContent> => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'scraping',
|
||||
error: null,
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await webSearchService.extractUrl(url);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'completed',
|
||||
error: null,
|
||||
}));
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Erro desconhecido ao extrair URL';
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reseta o estado
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
setState({
|
||||
...DEFAULT_STATE,
|
||||
isEnabled: state.isEnabled, // Manter preferência
|
||||
});
|
||||
}, [state.isEnabled]);
|
||||
|
||||
/**
|
||||
* Limpa cache
|
||||
*/
|
||||
const clearCache = useCallback(() => {
|
||||
webSearchService.clearCache();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
setEnabled,
|
||||
search,
|
||||
extractUrl,
|
||||
reset,
|
||||
clearCache,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user