Add tool usage and fix messaging history

This commit is contained in:
2025-10-18 04:52:58 -03:00
parent 7113d72a7c
commit 5e8fdd4925
4 changed files with 504 additions and 15 deletions

186
lib/ai/tools.js Normal file
View 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
View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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) {