mirror of
https://github.com/alexankitty/Myrient-Search-Engine.git
synced 2026-01-15 16:33:15 -03:00
Add tool usage and fix messaging history
This commit is contained in:
186
lib/ai/tools.js
Normal file
186
lib/ai/tools.js
Normal file
@@ -0,0 +1,186 @@
|
||||
// AI Tool definitions for function calling
|
||||
import Searcher from '../search/search.js';
|
||||
|
||||
// Initialize search instance
|
||||
const searchFields = ["filename", "category", "type", "region"];
|
||||
const searcher = new Searcher(searchFields);
|
||||
|
||||
// Default search settings
|
||||
const defaultSearchSettings = {
|
||||
boost: {
|
||||
filename: 2,
|
||||
category: 1,
|
||||
type: 1,
|
||||
region: 1
|
||||
},
|
||||
combineWith: "AND",
|
||||
fields: searchFields,
|
||||
fuzzy: 0,
|
||||
prefix: true,
|
||||
hideNonGame: true,
|
||||
useOldResults: false,
|
||||
pageSize: 10,
|
||||
page: 0,
|
||||
sort: ""
|
||||
};
|
||||
|
||||
/**
|
||||
* Available tools for the AI assistant
|
||||
*/
|
||||
export const tools = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_games",
|
||||
description: "Search for retro games and ROMs in the Myrient database. Use simple, flexible text searches for best results. The search is fuzzy and will find partial matches.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "The search query - game name or title. Examples: 'Super Mario', 'The Last of Us', 'Final Fantasy'. Keep it simple for best results."
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Maximum number of results to return (1-50, default 10)",
|
||||
minimum: 1,
|
||||
maximum: 50
|
||||
}
|
||||
},
|
||||
required: ["query"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_search_suggestions",
|
||||
description: "Get search suggestions based on a partial query. Useful for helping users discover games or correct typos.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "Partial search query to get suggestions for"
|
||||
}
|
||||
},
|
||||
required: ["query"]
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute a tool call
|
||||
*/
|
||||
export async function executeToolCall(toolCall) {
|
||||
const { name, arguments: argsString } = toolCall.function;
|
||||
|
||||
try {
|
||||
// Parse arguments from JSON string
|
||||
let args;
|
||||
try {
|
||||
args = typeof argsString === 'string' ? JSON.parse(argsString) : argsString;
|
||||
} catch (parseError) {
|
||||
throw new Error(`Invalid JSON arguments for ${name}: ${parseError.message}`);
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'search_games':
|
||||
return await searchGames(args);
|
||||
case 'get_search_suggestions':
|
||||
return await getSearchSuggestions(args);
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Tool execution error for ${name}:`, error);
|
||||
return {
|
||||
error: `Failed to execute ${name}: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for games using the existing search infrastructure
|
||||
*/
|
||||
async function searchGames(args) {
|
||||
const { query, limit = 10 } = args;
|
||||
|
||||
if (!query || typeof query !== 'string') {
|
||||
throw new Error('Query is required and must be a string');
|
||||
}
|
||||
|
||||
// Build search options - simplified for fuzzy/flexible search
|
||||
const searchOptions = { ...defaultSearchSettings };
|
||||
searchOptions.pageSize = Math.min(Math.max(1, limit), 50);
|
||||
|
||||
// Enable fuzzy search for better matching
|
||||
searchOptions.fuzzy = 1; // Allow some typos/variations
|
||||
searchOptions.prefix = true; // Allow partial matches
|
||||
|
||||
try {
|
||||
const results = await searcher.findAllMatches(query.trim(), searchOptions);
|
||||
|
||||
// Use results as-is without strict filtering
|
||||
let filteredItems = results.items;
|
||||
|
||||
// Format results for the AI
|
||||
const formattedResults = filteredItems.slice(0, searchOptions.pageSize).map(item => ({
|
||||
id: item.file.id,
|
||||
filename: item.file.filename,
|
||||
category: item.file.category,
|
||||
type: item.file.type,
|
||||
region: item.file.region,
|
||||
size: item.file.size,
|
||||
score: item.score,
|
||||
// Add URLs for linking to games
|
||||
urls: {
|
||||
info: `/info/${item.file.id}`,
|
||||
play: `/play/${item.file.id}`, // For emulator (if compatible)
|
||||
download: item.file.path // Direct download link
|
||||
},
|
||||
metadata: item.metadata ? {
|
||||
title: item.metadata.title,
|
||||
description: item.metadata.summary,
|
||||
releaseDate: item.metadata.first_release_date,
|
||||
rating: item.metadata.rating,
|
||||
genres: item.metadata.genres
|
||||
} : null
|
||||
}));
|
||||
|
||||
return {
|
||||
query,
|
||||
results: formattedResults,
|
||||
total_found: results.count,
|
||||
total_returned: formattedResults.length,
|
||||
search_time: results.elapsed
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Search failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search suggestions
|
||||
*/
|
||||
async function getSearchSuggestions(args) {
|
||||
const { query } = args;
|
||||
|
||||
if (!query || typeof query !== 'string') {
|
||||
throw new Error('Query is required and must be a string');
|
||||
}
|
||||
|
||||
try {
|
||||
const suggestions = await searcher.getSuggestions(query.trim(), defaultSearchSettings);
|
||||
|
||||
return {
|
||||
query,
|
||||
suggestions: suggestions || []
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get suggestions: ${error.message}`);
|
||||
}
|
||||
}
|
||||
239
server.js
239
server.js
@@ -573,16 +573,61 @@ About Myrient:
|
||||
- The search engine indexes thousands of games from various gaming systems and regions
|
||||
|
||||
Your role:
|
||||
- Help users find games they're looking for
|
||||
- Help users find games they're looking for by using the search tools available to you
|
||||
- Provide information about gaming history, consoles, and game recommendations
|
||||
- Answer questions about how to use the search features
|
||||
- Be knowledgeable about retro gaming but stay focused on being helpful
|
||||
- Keep responses concise and friendly
|
||||
- When users ask for games, always use the search_games tool to find them
|
||||
- Keep responses SHORT, CONCISE and SIMPLE - the chat interface is small
|
||||
- Present search results as simple lists, NOT tables (tables don't fit in the small chat window)
|
||||
- Use bullet points or numbered lists instead of tables
|
||||
- Limit responses to 3-5 game recommendations maximum to keep it readable
|
||||
- If users ask about downloading or legal issues, remind them that Myrient focuses on preservation
|
||||
|
||||
Current search context: The user is on a retro gaming search website and may be looking for specific games or gaming information.`;
|
||||
IMPORTANT SEARCH STRATEGY:
|
||||
- When users describe a game, THINK about what the actual game title might be before searching
|
||||
- Don't search literal descriptions - identify the likely game name first
|
||||
- Use SIMPLE searches with just the game title for best results
|
||||
- The search is fuzzy and will find partial matches - keep queries simple
|
||||
- If first search fails or returns few results, try alternative searches with different terms
|
||||
- For empty results, suggest the user try different search terms or check spelling
|
||||
|
||||
const aiResponse = await fetch(apiUrl, {
|
||||
GAME RECOMMENDATION STRATEGY:
|
||||
- When users ask for "best [console] games", don't search the console name
|
||||
- Instead, search for specific popular titles for that console
|
||||
- EFFICIENCY: Limit to 2-3 searches maximum for recommendations to avoid hitting rate limits
|
||||
- Stop searching when you have enough games (3-5) for a good recommendation
|
||||
- Focus on well-known AAA titles, not obscure indie games
|
||||
|
||||
Available Tools:
|
||||
- search_games: Fuzzy text search for games by title/name (returns URLs for each game)
|
||||
- get_search_suggestions: Get search suggestions for partial queries
|
||||
|
||||
CRITICAL LINKING RULES:
|
||||
- NEVER make up or guess URLs - ONLY use URLs from search tool results
|
||||
- When mentioning specific games found via search_games tool, ALWAYS link to them using EXACTLY the urls.info value from the search results
|
||||
- Format: [Game Title](EXACT_INFO_URL_FROM_SEARCH_RESULTS)
|
||||
- Do NOT create links like /info/123 - use the EXACT urls.info field from the tool response
|
||||
- If you haven't searched for a game using the tool, do NOT create any links for it
|
||||
- Only link to games that were actually returned by the search_games tool with their provided URLs`;
|
||||
|
||||
// Import tools dynamically
|
||||
const { tools, executeToolCall } = await import('./lib/ai/tools.js');
|
||||
|
||||
// Build conversation history
|
||||
let messages = [
|
||||
{ role: 'system', content: systemPrompt }
|
||||
];
|
||||
|
||||
// Add conversation history if provided
|
||||
if (req.body.conversation && Array.isArray(req.body.conversation)) {
|
||||
messages = messages.concat(req.body.conversation);
|
||||
}
|
||||
|
||||
// Add current user message
|
||||
messages.push({ role: 'user', content: message });
|
||||
|
||||
let aiResponse = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -591,11 +636,10 @@ Current search context: The user is on a retro gaming search website and may be
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: message }
|
||||
],
|
||||
max_tokens: 500,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
tool_choice: 'auto',
|
||||
max_tokens: 1000,
|
||||
temperature: 0.7,
|
||||
stream: false
|
||||
})
|
||||
@@ -603,7 +647,13 @@ Current search context: The user is on a retro gaming search website and may be
|
||||
|
||||
if (!aiResponse.ok) {
|
||||
const errorData = await aiResponse.json().catch(() => ({}));
|
||||
console.error('AI API Error:', aiResponse.status, errorData);
|
||||
console.error('AI API Error on initial request:');
|
||||
console.error('Status:', aiResponse.status);
|
||||
console.error('Error data:', errorData);
|
||||
console.error('Request details:');
|
||||
console.error('- Model:', model);
|
||||
console.error('- Messages count:', messages.length);
|
||||
console.error('- User message:', message.substring(0, 100) + '...');
|
||||
|
||||
// Handle specific error cases
|
||||
if (aiResponse.status === 401) {
|
||||
@@ -621,7 +671,7 @@ Current search context: The user is on a retro gaming search website and may be
|
||||
}
|
||||
}
|
||||
|
||||
const aiData = await aiResponse.json();
|
||||
let aiData = await aiResponse.json();
|
||||
|
||||
if (!aiData.choices || aiData.choices.length === 0) {
|
||||
return res.status(503).json({
|
||||
@@ -629,9 +679,172 @@ Current search context: The user is on a retro gaming search website and may be
|
||||
});
|
||||
}
|
||||
|
||||
const response = aiData.choices[0].message.content.trim();
|
||||
let assistantMessage = aiData.choices[0].message;
|
||||
let toolCallsCount = 0; // Track tool calls executed
|
||||
let toolsUsed = []; // Track which tools were used
|
||||
|
||||
res.json({ response });
|
||||
console.log('Initial AI request successful');
|
||||
|
||||
// Handle multiple rounds of tool calls
|
||||
let maxToolRounds = 3; // Prevent infinite loops and token exhaustion
|
||||
let currentRound = 0;
|
||||
|
||||
while (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0 && currentRound < maxToolRounds) {
|
||||
currentRound++;
|
||||
const roundToolCalls = assistantMessage.tool_calls.length;
|
||||
const roundToolsUsed = assistantMessage.tool_calls.map(tc => tc.function.name);
|
||||
|
||||
console.log(`Round ${currentRound}: AI wants to use ${roundToolCalls} tools: ${roundToolsUsed.join(', ')}`);
|
||||
|
||||
// Track total tools across all rounds
|
||||
toolCallsCount += roundToolCalls;
|
||||
toolsUsed = toolsUsed.concat(roundToolsUsed);
|
||||
|
||||
// Add assistant message with tool calls to conversation
|
||||
messages.push(assistantMessage);
|
||||
|
||||
// Execute each tool call in this round
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
try {
|
||||
const toolResult = await executeToolCall(toolCall);
|
||||
|
||||
// Add tool result to conversation
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: JSON.stringify(toolResult)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Tool execution error:', error);
|
||||
// Add error result
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCall.id,
|
||||
content: JSON.stringify({ error: error.message })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get AI response after this round of tool execution
|
||||
console.log(`Making AI request after round ${currentRound} tool execution...`);
|
||||
aiResponse = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'User-Agent': 'Myrient-Search-Engine/1.0'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
tool_choice: 'auto',
|
||||
max_tokens: 1000,
|
||||
temperature: 0.7,
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
|
||||
if (!aiResponse.ok) {
|
||||
const errorData = await aiResponse.json().catch(() => ({}));
|
||||
console.error(`AI API Error after round ${currentRound} tool execution:`);
|
||||
console.error('Status:', aiResponse.status);
|
||||
console.error('Error data:', errorData);
|
||||
console.error('Request details:');
|
||||
console.error('- Model:', model);
|
||||
console.error('- Messages count:', messages.length);
|
||||
console.error('- Tools used:', toolsUsed);
|
||||
|
||||
// Handle specific error cases
|
||||
if (aiResponse.status === 429) {
|
||||
// Extract wait time from error message if available
|
||||
let waitTime = 5000; // Default 5 seconds
|
||||
if (errorData.error?.message) {
|
||||
const waitMatch = errorData.error.message.match(/Please try again in ([\d.]+)s/);
|
||||
if (waitMatch) {
|
||||
waitTime = Math.ceil(parseFloat(waitMatch[1]) * 1000) + 1000; // Add 1 extra second
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Rate limit hit after tool execution. Waiting ${waitTime/1000}s and retrying once...`);
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
|
||||
const retryResponse = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'User-Agent': 'Myrient-Search-Engine/1.0'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
tool_choice: 'auto',
|
||||
max_tokens: 1000,
|
||||
temperature: 0.7,
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
|
||||
if (retryResponse.ok) {
|
||||
console.log('Retry successful after rate limit');
|
||||
aiData = await retryResponse.json();
|
||||
assistantMessage = aiData.choices[0].message;
|
||||
} else {
|
||||
console.error('Retry also failed with status:', retryResponse.status);
|
||||
return res.status(429).json({
|
||||
error: 'AI service is currently busy processing your request. Please try again in a moment.'
|
||||
});
|
||||
}
|
||||
} else if (aiResponse.status === 401) {
|
||||
return res.status(503).json({
|
||||
error: 'AI service authentication failed. Please contact the administrator.'
|
||||
});
|
||||
} else {
|
||||
return res.status(503).json({
|
||||
error: 'AI service encountered an error while processing your request. Please try again later.'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log(`AI request after round ${currentRound} tool execution successful`);
|
||||
aiData = await aiResponse.json();
|
||||
assistantMessage = aiData.choices[0].message;
|
||||
|
||||
console.log(`Round ${currentRound} response - has tool_calls:`, !!assistantMessage.tool_calls);
|
||||
console.log(`Round ${currentRound} response - has content:`, !!assistantMessage.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRound >= maxToolRounds && assistantMessage.tool_calls) {
|
||||
console.warn('Maximum tool rounds reached, AI still wants to use tools. Stopping.');
|
||||
}
|
||||
|
||||
if (currentRound === 0) {
|
||||
console.log('No tool calls needed, using initial response');
|
||||
} else {
|
||||
console.log(`Total rounds completed: ${currentRound}`);
|
||||
}
|
||||
|
||||
console.log('Final tool calls check - has tool_calls:', !!assistantMessage.tool_calls);
|
||||
console.log('Final tool calls check - has content:', !!assistantMessage.content);
|
||||
|
||||
console.log('Final assistant message structure:', JSON.stringify(assistantMessage, null, 2));
|
||||
console.log('Assistant message content:', assistantMessage.content);
|
||||
console.log('Assistant message content type:', typeof assistantMessage.content);
|
||||
console.log('Assistant message keys:', Object.keys(assistantMessage));
|
||||
|
||||
const response = assistantMessage.content?.trim() || 'Something went wrong';
|
||||
console.log('Final response after processing:', response.substring(0, 100) + '...');
|
||||
console.log('Tools used in this request:', toolsUsed);
|
||||
|
||||
// Return the response along with updated conversation
|
||||
res.json({
|
||||
response,
|
||||
conversation: messages.slice(1), // Exclude system message from returned conversation
|
||||
tool_calls_made: toolCallsCount,
|
||||
tools_used: toolsUsed
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI Chat Error:', error);
|
||||
|
||||
@@ -558,3 +558,34 @@
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tool Usage Indicator */
|
||||
.ai-tool-usage-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 10px 0 5px 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ai-tool-indicator {
|
||||
background: rgba(255, 189, 51, 0.1);
|
||||
border: 1px solid rgba(255, 189, 51, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 6px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #ffbd33;
|
||||
}
|
||||
|
||||
.ai-tool-icon {
|
||||
font-size: 14px;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.ai-tool-indicator small {
|
||||
font-size: 11px;
|
||||
color: #b0b8c1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ class AIChat {
|
||||
this.isTyping = false;
|
||||
this.hasMessages = false;
|
||||
|
||||
// Track conversation history for tool calling
|
||||
this.conversation = [];
|
||||
|
||||
// Initialize translations
|
||||
this.translations = window.aiChatTranslations || {
|
||||
typing_indicator: 'AI is thinking',
|
||||
@@ -177,13 +180,19 @@ class AIChat {
|
||||
this.showTypingIndicator();
|
||||
|
||||
try {
|
||||
// Send to backend
|
||||
// Add user message to conversation history
|
||||
this.conversation.push({ role: 'user', content: message });
|
||||
|
||||
// Send to backend with conversation history
|
||||
const response = await fetch('/api/ai-chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message })
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
conversation: this.conversation.slice(-10) // Send last 10 messages for context
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -193,10 +202,24 @@ class AIChat {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update conversation history
|
||||
if (data.conversation) {
|
||||
this.conversation = data.conversation;
|
||||
}
|
||||
|
||||
// Remove typing indicator and add AI response
|
||||
this.hideTypingIndicator();
|
||||
|
||||
// Show tool usage indicator if tools were used
|
||||
if (data.tool_calls_made && data.tool_calls_made > 0) {
|
||||
this.addToolUsageIndicator(data.tool_calls_made, data.tools_used || []);
|
||||
}
|
||||
|
||||
this.addMessage(data.response, 'ai');
|
||||
|
||||
// Add AI response to conversation history
|
||||
this.conversation.push({ role: 'assistant', content: data.response });
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI Chat Error:', error);
|
||||
this.hideTypingIndicator();
|
||||
@@ -229,6 +252,42 @@ class AIChat {
|
||||
`;
|
||||
|
||||
this.messagesContainer.appendChild(messageDiv);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
addToolUsageIndicator(toolCount, toolsUsed = []) {
|
||||
const indicatorDiv = document.createElement('div');
|
||||
indicatorDiv.className = 'ai-tool-usage-indicator';
|
||||
|
||||
// Create user-friendly tool names
|
||||
const friendlyToolNames = {
|
||||
'search_games': 'game search',
|
||||
'get_search_suggestions': 'search suggestions'
|
||||
};
|
||||
|
||||
let toolText;
|
||||
if (toolsUsed.length > 0) {
|
||||
const friendlyNames = toolsUsed.map(tool => friendlyToolNames[tool] || tool);
|
||||
if (friendlyNames.length === 1) {
|
||||
toolText = `Used ${friendlyNames[0]}`;
|
||||
} else {
|
||||
toolText = `Used ${friendlyNames.join(' & ')}`;
|
||||
}
|
||||
} else {
|
||||
// Fallback to generic text
|
||||
const genericToolText = toolCount === 1 ? 'tool' : 'tools';
|
||||
toolText = `Used ${toolCount} ${genericToolText}`;
|
||||
}
|
||||
|
||||
indicatorDiv.innerHTML = `
|
||||
<div class="ai-tool-indicator">
|
||||
<span class="ai-tool-icon">🔧</span>
|
||||
<small>${toolText}</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.messagesContainer.appendChild(indicatorDiv);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
formatMessage(message) {
|
||||
|
||||
Reference in New Issue
Block a user