diff --git a/content/develop/ai/agent-builder/_index.md b/content/develop/ai/agent-builder/_index.md index 8b27ce90fa..87bfea9d7e 100644 --- a/content/develop/ai/agent-builder/_index.md +++ b/content/develop/ai/agent-builder/_index.md @@ -39,7 +39,7 @@ The agent builder will generate complete, working code examples for your chosen ## Features -- **Multiple programming languages**: Generate code in Python, with JavaScript (Node.js), Java, and C# coming soon +- **Multiple programming languages**: Generate code in Python and JavaScript (Node.js), with Java and C# coming soon - **LLM integration**: Support for OpenAI, Anthropic Claude, and Llama 2 - **Redis optimized**: Uses Redis data structures for optimal performance diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js new file mode 100644 index 0000000000..daaf314426 --- /dev/null +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -0,0 +1,251 @@ +/* + * Redis Conversational Agent (Node.js) + * Uses node-redis with Redis Search for semantic message history + * + * Requires Redis Stack 6.2+ or Redis 8 with the Search module for JSON + * vector indexing. The vector field is stored as a JSON array of floats, + * which is the correct on-disk format for JSON-backed vector indexes. + * + * To run this code: + * Install dependencies: + * npm install redis openai dotenv + * + * Set environment variables: + * LLM_API_KEY=your_${formData.llmModel.toLowerCase()}_api_key + * LLM_API_BASE_URL=your_base_url (optional, default: ${CONFIG.models[formData.llmModel].baseUrl}) + * LLM_MODEL=your_model_name (optional, default: ${CONFIG.models[formData.llmModel].defaultModel}) + * EMBEDDING_MODEL=your_embed_model (optional, default: text-embedding-3-small) + * VECTOR_DIM=1536 (optional, must match your embedding model's output dimension) + * REDIS_URL=redis://localhost:6379 + * (or use REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_USERNAME separately) + */ + +require('dotenv').config(); +const { createClient } = require('redis'); +const OpenAI = require('openai'); + +const INDEX_NAME = 'message_history_idx'; +const MESSAGE_PREFIX = 'message:'; +const RECENT_KEY = (session) => `recent:${session}`; +const EMBEDDING_MODEL = process.env.EMBEDDING_MODEL || 'text-embedding-3-small'; +const VECTOR_DIM = parseInt(process.env.VECTOR_DIM) || 1536; +const RECENT_WINDOW = 6; // always include this many recent turns in context +const SEMANTIC_TOP_K = 4; // additional turns retrieved by semantic similarity +const MAX_CONTENT_CHARS = 2000; + +class ConversationalAgent { + constructor(sessionName = 'chat') { + this.sessionName = sessionName; + this.messageCount = 0; + this._dimValidated = false; + + this.llmApiKey = process.env.LLM_API_KEY; + if (!this.llmApiKey) throw new Error('LLM_API_KEY environment variable is required'); + + this.llmBaseUrl = process.env.LLM_API_BASE_URL || '${CONFIG.models[formData.llmModel].baseUrl}'; + this.llmModel = process.env.LLM_MODEL || '${CONFIG.models[formData.llmModel].defaultModel}'; + + this.openai = new OpenAI({ apiKey: this.llmApiKey, baseURL: this.llmBaseUrl }); + this.redisClient = null; + } + + async connect() { + const clientOptions = process.env.REDIS_URL + ? { url: process.env.REDIS_URL } + : { + socket: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT) || 6379, + }, + password: process.env.REDIS_PASSWORD || undefined, + username: process.env.REDIS_USERNAME || 'default', + }; + + this.redisClient = createClient(clientOptions); + this.redisClient.on('error', (err) => console.error('Redis error:', err)); + await this.redisClient.connect(); + console.log('Connected to Redis successfully'); + + await this._ensureIndex(); + console.log('LLM configured:', this.llmModel); + console.log('Embedding model:', EMBEDDING_MODEL, `(VECTOR_DIM=${VECTOR_DIM})`); + } + + async _ensureIndex() { + try { + await this.redisClient.ft.info(INDEX_NAME); + } catch { + await this.redisClient.ft.create( + INDEX_NAME, + { + '$.role': { type: 'TAG', AS: 'role' }, + '$.content': { type: 'TEXT', AS: 'content' }, + '$.session': { type: 'TAG', AS: 'session' }, + '$.embedding': { + type: 'VECTOR', + AS: 'embedding', + ALGORITHM: 'FLAT', + TYPE: 'FLOAT32', + DIM: VECTOR_DIM, + DISTANCE_METRIC: 'COSINE', + }, + }, + { ON: 'JSON', PREFIX: MESSAGE_PREFIX } + ); + console.log('Created search index:', INDEX_NAME); + } + } + + async _embed(text) { + const response = await this.openai.embeddings.create({ + model: EMBEDDING_MODEL, + input: text, + }); + const embedding = response.data[0].embedding; + + // Validate dimension on first call. If this throws, either set VECTOR_DIM + // to the correct value in your environment, or recreate the index. + if (!this._dimValidated) { + if (embedding.length !== VECTOR_DIM) { + throw new Error( + `Embedding model '${EMBEDDING_MODEL}' returned ${embedding.length} dimensions ` + + `but VECTOR_DIM is ${VECTOR_DIM}. ` + + `Set VECTOR_DIM=${embedding.length} and recreate the index.` + ); + } + this._dimValidated = true; + } + + return embedding; // plain JS number array + } + + _toQueryBuffer(embedding) { + return Buffer.from(new Float32Array(embedding).buffer); + } + + async _storeMessage(role, content) { + const truncated = content.slice(0, MAX_CONTENT_CHARS); + const embedding = await this._embed(truncated); + const key = `${MESSAGE_PREFIX}${this.sessionName}:${Date.now()}_${this.messageCount++}`; + + await this.redisClient.json.set(key, '$', { + role, + content: truncated, + session: this.sessionName, + embedding, // stored as JSON array of floats, required for JSON vector index + }); + + // Track insertion order for recent-turn retrieval + await this.redisClient.rPush(RECENT_KEY(this.sessionName), key); + await this.redisClient.lTrim(RECENT_KEY(this.sessionName), -RECENT_WINDOW * 4, -1); + } + + async _getRecentMessages() { + const keys = await this.redisClient.lRange(RECENT_KEY(this.sessionName), 0, -1); + if (!keys.length) return []; + const docs = await this.redisClient.json.mGet(keys, '$'); + return docs + .filter(Boolean) + .flatMap((d) => d) + .filter(Boolean) + .map((m) => ({ role: m.role, content: m.content, _key: m._key })); + } + + async _getSemanticMessages(query) { + const queryBuffer = this._toQueryBuffer(await this._embed(query)); + const results = await this.redisClient.ft.search( + INDEX_NAME, + `(@session:{${this.sessionName}})=>[KNN ${SEMANTIC_TOP_K} @embedding $vec AS score]`, + { + PARAMS: { vec: queryBuffer }, + RETURN: ['role', 'content', '__key'], + SORTBY: { BY: 'score', DIRECTION: 'ASC' }, + DIALECT: 2, + } + ); + return results.documents.map((doc) => ({ + role: doc.value.role, + content: doc.value.content, + _key: doc.id, + })); + } + + async _buildContext(userInput) { + // Hybrid: recent turns for conversational coherence + semantic search for deeper context. + const [recent, semantic] = await Promise.all([ + this._getRecentMessages().catch(() => []), + this._getSemanticMessages(userInput).catch(() => []), + ]); + + // Deduplicate by key, preserving recent turns first + const seen = new Set(recent.map((m) => m._key)); + const extra = semantic.filter((m) => !seen.has(m._key)); + + return [...recent, ...extra].map(({ role, content }) => ({ role, content })); + } + + async chat(userInput) { + const context = await this._buildContext(userInput); + + const messages = [ + { + role: 'system', + content: 'You are a helpful assistant that answers questions based on the conversation history.', + }, + ...context, + { role: 'user', content: userInput }, + ]; + + const response = await this.openai.chat.completions.create({ + model: this.llmModel, + messages, + }); + + const assistantResponse = response.choices[0]?.message?.content; + if (!assistantResponse) throw new Error('Empty response from LLM'); + + await this._storeMessage('user', userInput); + await this._storeMessage('assistant', assistantResponse); + + return assistantResponse; + } + + async disconnect() { + if (this.redisClient) await this.redisClient.disconnect(); + } +} + +async function main() { + const agent = new ConversationalAgent(); + try { + await agent.connect(); + console.log(await agent.chat('Tell me about yourself.')); + } catch (err) { + console.error('Failed to initialize agent:', err.message); + await agent.disconnect(); + process.exit(1); + } + + const readline = require('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + const askQuestion = () => { + rl.question('Enter a prompt: ', async (input) => { + if (['quit', 'exit', 'bye'].includes(input.toLowerCase())) { + console.log('Goodbye!'); + rl.close(); + await agent.disconnect(); + return; + } + try { + console.log(await agent.chat(input)); + } catch (err) { + console.error('Error:', err.message); + } + askQuestion(); + }); + }; + askQuestion(); +} + +main(); diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js new file mode 100644 index 0000000000..fdc6eb5824 --- /dev/null +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -0,0 +1,363 @@ +/* + * Redis Recommendation Engine (Node.js) + * Uses node-redis with Redis Search for movie recommendations + * + * To run this code: + * Install dependencies: + * npm install redis openai dotenv csv-parse + * + * Set environment variables: + * LLM_API_KEY=your_${formData.llmModel.toLowerCase()}_api_key + * LLM_API_BASE_URL=your_base_url (optional, default: ${CONFIG.models[formData.llmModel].baseUrl}) + * LLM_MODEL=your_model_name (optional, default: ${CONFIG.models[formData.llmModel].defaultModel}) + * REDIS_URL=redis://localhost:6379 + * (or use REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_USERNAME separately) + * + * Download datasets: + * https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/collaborative-filtering/ratings_small.csv + * https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/collaborative-filtering/movies_metadata.csv + * Place them in datasets/collaborative_filtering/ relative to this file. + */ + +require('dotenv').config(); +const { createClient } = require('redis'); +const OpenAI = require('openai'); +const { parse } = require('csv-parse/sync'); +const fs = require('fs'); +const path = require('path'); + +const INDEX_NAME = 'movies_idx'; +const MOVIE_PREFIX = 'movie:'; + +const CONFIG = { + maxResults: 10, + defaultResults: 5, + minRevenueFilter: 30_000_000, + validSortFields: new Set(['popularityScore', 'avgRating', 'ratingCount', 'revenue']), + validSortOrders: new Set(['DESC', 'ASC']), +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Parse genres from movies_metadata.csv. + * The field is stored as a Python-style list of dicts, e.g.: + * "[{'id': 28, 'name': 'Action'}, {'id': 12, 'name': 'Adventure'}]" + * Returns a comma-separated string for use as a Redis TAG field. + */ +function parseGenres(raw) { + if (!raw || raw === '[]') return ''; + try { + const json = raw.replace(/'/g, '"').replace(/None/g, 'null').replace(/True/g, 'true').replace(/False/g, 'false'); + const parsed = JSON.parse(json); + return parsed.map((g) => g?.name).filter(Boolean).join(','); + } catch { + console.warn('parseGenres: could not parse genres field, storing empty string. Raw value:', raw?.slice(0, 80)); + return ''; + } +} + +function safeNumber(value, fallback = 0) { + const n = Number(value); + return isFinite(n) ? n : fallback; +} + +/** + * Validate and sanitize LLM-returned query params. + * Rejects any field that doesn't match expected types or allowed values. + */ +function validateQueryParams(raw) { + return { + genres: Array.isArray(raw?.genres) + ? raw.genres.filter((g) => typeof g === 'string' && g.trim()) + : null, + minRating: typeof raw?.minRating === 'number' && raw.minRating >= 0 && raw.minRating <= 10 + ? raw.minRating + : null, + minReviews: typeof raw?.minReviews === 'number' && raw.minReviews > 0 + ? Math.floor(raw.minReviews) + : null, + maxResults: typeof raw?.maxResults === 'number' + ? Math.min(Math.max(1, Math.floor(raw.maxResults)), CONFIG.maxResults) + : CONFIG.defaultResults, + sortBy: CONFIG.validSortFields.has(raw?.sortBy) + ? raw.sortBy + : 'popularityScore', + sortOrder: CONFIG.validSortOrders.has(raw?.sortOrder?.toUpperCase?.()) + ? raw.sortOrder.toUpperCase() + : 'DESC', + revenueFilter: raw?.revenueFilter === true, + }; +} + +// --------------------------------------------------------------------------- +// Agent class +// --------------------------------------------------------------------------- + +class RecommendationAgent { + constructor() { + this.llmApiKey = process.env.LLM_API_KEY; + if (!this.llmApiKey) throw new Error('LLM_API_KEY environment variable is required'); + + this.llmBaseUrl = process.env.LLM_API_BASE_URL || '${CONFIG.models[formData.llmModel].baseUrl}'; + this.llmModel = process.env.LLM_MODEL || '${CONFIG.models[formData.llmModel].defaultModel}'; + + this.openai = new OpenAI({ apiKey: this.llmApiKey, baseURL: this.llmBaseUrl }); + this.redisClient = null; + this.indexReady = false; + } + + async connect() { + const clientOptions = process.env.REDIS_URL + ? { url: process.env.REDIS_URL } + : { + socket: { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT) || 6379, + }, + password: process.env.REDIS_PASSWORD || undefined, + username: process.env.REDIS_USERNAME || 'default', + }; + + this.redisClient = createClient(clientOptions); + this.redisClient.on('error', (err) => console.error('Redis error:', err)); + await this.redisClient.connect(); + console.log('Connected to Redis successfully'); + console.log('LLM configured:', this.llmModel); + + await this._setupMovieIndex(); + } + + async _indexExists() { + try { + const info = await this.redisClient.ft.info(INDEX_NAME); + return parseInt(info.numDocs) > 0; + } catch { + return false; + } + } + + async _setupMovieIndex() { + // Skip loading if the index already exists and has documents. + if (await this._indexExists()) { + console.log('Movie index already loaded, skipping dataset import.'); + this.indexReady = true; + return; + } + + const ratingsFile = path.join('datasets', 'collaborative_filtering', 'ratings_small.csv'); + const moviesFile = path.join('datasets', 'collaborative_filtering', 'movies_metadata.csv'); + + if (!fs.existsSync(ratingsFile) || !fs.existsSync(moviesFile)) { + console.warn('Movie datasets not found. Skipping index setup.'); + console.warn(` Expected: ${ratingsFile}`); + console.warn(` Expected: ${moviesFile}`); + return; + } + + console.log('Loading movie datasets...'); + + let ratings, movies; + try { + ratings = parse(fs.readFileSync(ratingsFile), { columns: true, cast: true }); + movies = parse(fs.readFileSync(moviesFile), { columns: true, cast: true }); + } catch (err) { + console.error('Failed to parse dataset files:', err.message); + return; + } + + if (!ratings.length || !movies.length) { + console.error('One or more dataset files are empty.'); + return; + } + + // Aggregate ratings per movie + const stats = {}; + for (const r of ratings) { + if (!r.movieId || !isFinite(r.rating)) continue; + if (!stats[r.movieId]) stats[r.movieId] = { count: 0, total: 0 }; + stats[r.movieId].count++; + stats[r.movieId].total += r.rating; + } + + // Merge metadata with aggregated stats + const merged = movies + .filter((m) => m.id && stats[String(m.id)]) + .map((m) => { + const s = stats[String(m.id)]; + const avgRating = s.total / s.count; + return { + movieId: String(m.id), + title: String(m.title || '').trim(), + genres: parseGenres(m.genres), // comma-separated TAG string + revenue: safeNumber(m.revenue), + ratingCount: s.count, + avgRating: Math.round(avgRating * 100) / 100, + popularityScore: Math.round(s.count * avgRating * 100) / 100, + }; + }) + .filter((m) => m.title); + + if (!merged.length) { + console.error('No valid movies found after merging datasets.'); + return; + } + + console.log(`Processed ${merged.length} movies`); + + // Drop existing index if present, ignoring "index not found" errors only. + try { + await this.redisClient.ft.dropIndex(INDEX_NAME); + } catch (err) { + if (!err.message?.includes('Unknown Index name')) throw err; + } + + await this.redisClient.ft.create( + INDEX_NAME, + { + '$.movieId': { type: 'TAG', AS: 'movieId' }, + '$.title': { type: 'TEXT', AS: 'title' }, + '$.genres': { type: 'TAG', AS: 'genres', SEPARATOR: ',' }, + '$.revenue': { type: 'NUMERIC', AS: 'revenue' }, + '$.ratingCount': { type: 'NUMERIC', AS: 'ratingCount' }, + '$.avgRating': { type: 'NUMERIC', AS: 'avgRating' }, + '$.popularityScore': { type: 'NUMERIC', AS: 'popularityScore' }, + }, + { ON: 'JSON', PREFIX: MOVIE_PREFIX } + ); + + // Load using a pipeline for efficiency + const pipeline = this.redisClient.multi(); + for (const movie of merged) { + pipeline.json.set(`${MOVIE_PREFIX}${movie.movieId}`, '$', movie); + } + await pipeline.exec(); + + this.indexReady = true; + console.log('Movie recommendation system initialized successfully!'); + } + + async _parseUserQuery(userQuery) { + const systemPrompt = `You are a movie recommendation assistant. Parse the user's query and return a JSON object with: +- "genres": array of genre name strings or null +- "minRating": minimum average rating (0-10) or null +- "minReviews": minimum review count or null +- "maxResults": number of results (default 5, max 10) +- "sortBy": one of "popularityScore", "avgRating", "ratingCount", "revenue" +- "sortOrder": "DESC" or "ASC" +- "revenueFilter": true for blockbusters, null otherwise + +Return only valid JSON with no explanation or markdown.`; + + try { + const response = await this.openai.chat.completions.create({ + model: this.llmModel, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userQuery }, + ], + temperature: 0.1, + }); + + const raw = JSON.parse(response.choices[0]?.message?.content || '{}'); + return validateQueryParams(raw); // always returns a safe, validated object + } catch { + return validateQueryParams({}); // safe defaults on any failure + } + } + + async recommendMovies(userQuery) { + if (!this.indexReady) { + return 'The movie database is not available. Please check that the dataset files are present.'; + } + + const params = await this._parseUserQuery(userQuery); + + // Build filter parts — genres now filtered in Redis via TAG, not in JavaScript + const filterParts = []; + if (params.minRating) filterParts.push(`@avgRating:[${params.minRating} +inf]`); + if (params.minReviews) filterParts.push(`@ratingCount:[${params.minReviews} +inf]`); + if (params.revenueFilter) filterParts.push(`@revenue:[${CONFIG.minRevenueFilter} +inf]`); + if (params.genres?.length) { + // Redis TAG filter: match any of the requested genres + const tagList = params.genres.map((g) => g.replace(/[^a-zA-Z0-9 ]/g, '')).join('|'); + if (tagList) filterParts.push(`@genres:{${tagList}}`); + } + + const filterQuery = filterParts.length > 0 ? filterParts.join(' ') : '*'; + + let results; + try { + results = await this.redisClient.ft.search(INDEX_NAME, filterQuery, { + RETURN: ['title', 'genres', 'ratingCount', 'avgRating', 'popularityScore'], + SORTBY: { BY: params.sortBy, DIRECTION: params.sortOrder }, + LIMIT: { from: 0, size: params.maxResults }, + }); + } catch (err) { + console.error('Search error:', err.message); + return 'Sorry, there was an error searching the movie database.'; + } + + const movies = results?.documents?.map((d) => d.value).filter(Boolean) ?? []; + + if (!movies.length) { + return "Sorry, no movies found matching your criteria. Try adjusting your preferences."; + } + + let response = `Based on your request '${userQuery}', here are my recommendations:\n\n`; + movies.forEach((m, i) => { + response += `${i + 1}. ${m.title}\n`; + response += ` Genres: ${m.genres || 'N/A'}\n`; + response += ` Average Rating: ${parseFloat(m.avgRating || 0).toFixed(1)}/10 (${m.ratingCount || 0} reviews)\n`; + response += ` Popularity Score: ${parseFloat(m.popularityScore || 0).toFixed(1)}\n\n`; + }); + return response; + } + + async disconnect() { + if (this.redisClient) await this.redisClient.disconnect(); + } +} + +async function main() { + const agent = new RecommendationAgent(); + try { + await agent.connect(); + } catch (err) { + console.error('Failed to initialize agent:', err.message); + await agent.disconnect(); + process.exit(1); + } + + console.log('\nWelcome to the Redis Movie Recommendation Agent!'); + console.log("Ask for movie recommendations. Type 'quit' to exit.\n"); + console.log("Here's a quick demo:"); + console.log(await agent.recommendMovies('Show me some popular movies')); + + const readline = require('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + const askQuestion = () => { + rl.question('\nWhat kind of movies are you looking for? ', async (input) => { + if (['quit', 'exit', 'bye'].includes(input.toLowerCase())) { + console.log('Goodbye!'); + rl.close(); + await agent.disconnect(); + return; + } + if (input.trim()) { + try { + console.log('\n' + await agent.recommendMovies(input)); + } catch (err) { + console.error('Error:', err.message); + } + } + askQuestion(); + }); + }; + askQuestion(); +} + +main(); diff --git a/static/js/agent-builder.js b/static/js/agent-builder.js index 91f37ebee3..098a404687 100644 --- a/static/js/agent-builder.js +++ b/static/js/agent-builder.js @@ -426,8 +426,8 @@ } if (selectedLang) { - // Check if it's Python (fully supported) - if (selectedLang === 'python') { + // Check if it's a fully supported language + if (selectedLang === 'python' || selectedLang === 'javascript') { conversationState.selections.programmingLanguage = selectedLang; const config = CONFIG.languages[selectedLang]; @@ -445,9 +445,10 @@ const config = CONFIG.languages[selectedLang]; const languageName = config.name; - addMessage(`${languageName} support is coming soon. Currently, only Python is fully supported.`, 'bot'); - addMessage(`Would you like to build a Python agent instead?`, 'bot', [ - { value: 'python', label: 'Yes, use Python' }, + addMessage(`${languageName} support is coming soon. Currently, Python and JavaScript (Node.js) are fully supported.`, 'bot'); + addMessage(`Would you like to build an agent in a supported language instead?`, 'bot', [ + { value: 'python', label: 'Use Python' }, + { value: 'javascript', label: 'Use JavaScript (Node.js)' }, { value: 'wait', label: 'I\'ll wait for ' + languageName } ]); } @@ -520,8 +521,7 @@ java: '.java', csharp: '.cs' }; - const base = window.HUGO_BASEURL || ''; - const filename = `${base}code/agent-templates/${formData.programmingLanguage}/${formData.agentType}_agent${fileExtensions[formData.programmingLanguage]}`; + const filename = `/code/agent-templates/${formData.programmingLanguage}/${formData.agentType}_agent${fileExtensions[formData.programmingLanguage]}`; return loadTemplateFile(filename, formData) || genericTemplates[formData.programmingLanguage](formData); }