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/layouts/shortcodes/agent-builder.html b/layouts/shortcodes/agent-builder.html
index 8adeffb96b..c5a24cc09c 100644
--- a/layouts/shortcodes/agent-builder.html
+++ b/layouts/shortcodes/agent-builder.html
@@ -90,5 +90,7 @@
Generated Agent Code
+
+
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..a4283b7905
--- /dev/null
+++ b/static/code/agent-templates/javascript/conversational_agent.js
@@ -0,0 +1,276 @@
+/*
+ * 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})
+ * REDIS_URL=redis://localhost:6379
+ * (or use REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_USERNAME separately)
+ *
+ * Embeddings use a separate client so you can mix providers:
+ * EMBEDDING_API_KEY=your_key (optional - defaults to LLM_API_KEY)
+ * EMBEDDING_API_BASE_URL=your_url (optional - defaults to LLM_API_BASE_URL)
+ * EMBEDDING_MODEL=your_embed_model (optional, default: text-embedding-3-small;
+ * for Ollama use nomic-embed-text)
+ * VECTOR_DIM=1536 (optional, must match your embedding model's output dimension)
+ */
+
+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;
+
+ // For local providers (e.g. Ollama), any non-empty string works. For hosted providers, use your real key.
+ this.llmApiKey = process.env.LLM_API_KEY || 'no-key-needed';
+
+ 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 });
+
+ // Embeddings can use a different provider than chat completions.
+ // For Ollama users: set EMBEDDING_MODEL=nomic-embed-text (no extra keys needed).
+ // For Anthropic users: set EMBEDDING_API_KEY and EMBEDDING_API_BASE_URL to an
+ // OpenAI-compatible embedding endpoint (e.g. OpenAI or Ollama).
+ this.embedder = new OpenAI({
+ apiKey: process.env.EMBEDDING_API_KEY || this.llmApiKey,
+ baseURL: process.env.EMBEDDING_API_BASE_URL || 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.embedder.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.
+ // Before trimming, collect any keys that will be evicted and delete their documents
+ // so message JSON and embeddings don't accumulate in Redis indefinitely.
+ const listLen = await this.redisClient.lLen(RECENT_KEY(this.sessionName));
+ const evictCount = listLen - (RECENT_WINDOW * 2 - 1); // -1 because we haven't pushed yet
+ if (evictCount > 0) {
+ const toEvict = await this.redisClient.lRange(RECENT_KEY(this.sessionName), 0, evictCount - 1);
+ if (toEvict.length) await this.redisClient.del(toEvict);
+ }
+ await this.redisClient.rPush(RECENT_KEY(this.sessionName), key);
+ await this.redisClient.lTrim(RECENT_KEY(this.sessionName), -RECENT_WINDOW * 2, -1);
+ }
+
+ async _getRecentMessages() {
+ const keys = await this.redisClient.lRange(RECENT_KEY(this.sessionName), -(RECENT_WINDOW * 2), -1);
+ if (!keys.length) return [];
+ const docs = await this.redisClient.json.mGet(keys, '$');
+ return keys
+ .map((key, i) => ({ key, doc: docs[i]?.[0] }))
+ .filter(({ doc }) => doc != null)
+ .map(({ key, doc }) => ({ role: doc.role, content: doc.content, _key: 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, then sort chronologically — keys encode timestamp so
+ // lexicographic order preserves insertion time across both result sets.
+ const seen = new Set(recent.map((m) => m._key));
+ const extra = semantic.filter((m) => !seen.has(m._key));
+
+ return [...recent, ...extra]
+ .sort((a, b) => (a._key < b._key ? -1 : a._key > b._key ? 1 : 0))
+ .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..0c00799cfc
--- /dev/null
+++ b/static/code/agent-templates/javascript/recommendation_agent.js
@@ -0,0 +1,368 @@
+/*
+ * 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 <= 5
+ ? 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() {
+ // For local providers (e.g. Ollama), any non-empty string works. For hosted providers, use your real key.
+ this.llmApiKey = process.env.LLM_API_KEY || 'no-key-needed';
+
+ 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.num_docs ?? info.numDocs ?? '0') > 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 and its documents so stale movie keys don't survive the reload.
+ const indexExists = await this.redisClient.ft.info(INDEX_NAME).then(() => true).catch(() => false);
+ if (indexExists) await this.redisClient.ft.dropIndex(INDEX_NAME, { DD: true });
+
+ 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-5 star scale) 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 != null) filterParts.push(`@avgRating:[${params.minRating} +inf]`);
+ if (params.minReviews != null) 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.
+ // Hyphens (e.g. "Film-Noir") and spaces (e.g. "Science Fiction") are stored intact
+ // at ingest, so they must be preserved and backslash-escaped in the query rather
+ // than stripped, otherwise hyphenated and multi-word genres never match.
+ const tagList = params.genres
+ .map((g) => g.replace(/[^a-zA-Z0-9 \-]/g, '').trim()
+ .replace(/-/g, '\\-')
+ .replace(/ +/g, '\\ '))
+ .filter(Boolean)
+ .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)}/5 (${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..5ba73f373b 100644
--- a/static/js/agent-builder.js
+++ b/static/js/agent-builder.js
@@ -308,16 +308,21 @@
);
break;
- case 'model':
- suggestions = Object.entries(CONFIG.models).map(([key, config]) => ({
- value: key,
- label: config.name,
- icon: '🤖'
- })).filter(s =>
- s.label.toLowerCase().includes(lowerInput) ||
- CONFIG.models[s.value].keywords.some(k => k.includes(lowerInput))
- );
+ case 'model': {
+ const allowedModels = getModelChips(conversationState.selections.programmingLanguage).map(m => m.value);
+ suggestions = Object.entries(CONFIG.models)
+ .filter(([key]) => allowedModels.includes(key))
+ .map(([key, config]) => ({
+ value: key,
+ label: config.name,
+ icon: '🤖'
+ }))
+ .filter(s =>
+ s.label.toLowerCase().includes(lowerInput) ||
+ CONFIG.models[s.value].keywords.some(k => k.includes(lowerInput))
+ );
break;
+ }
}
return suggestions.slice(0, 5); // Limit to 5 suggestions
@@ -405,6 +410,19 @@
+ function getModelChips(language) {
+ const all = [
+ { value: 'openai', label: '🤖 OpenAI (GPT-4)' },
+ { value: 'anthropic', label: '🧠 Anthropic (Claude)' },
+ { value: 'llama3', label: '🦙 Llama 3' }
+ ];
+ // Anthropic's API is not OpenAI-compatible; JS templates use the OpenAI SDK
+ if (language === 'javascript') {
+ return all.filter(m => m.value !== 'anthropic');
+ }
+ return all;
+ }
+
function processLanguageSelection(input) {
let selectedLang = null;
@@ -426,8 +444,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];
@@ -435,19 +453,16 @@
// Move to next step
conversationState.step = 'model';
- addMessage('Finally, which AI model would you like to use?', 'bot', [
- { value: 'openai', label: '🤖 OpenAI (GPT-4)' },
- { value: 'anthropic', label: '🧠 Anthropic (Claude)' },
- { value: 'llama3', label: '🦙 Llama 3' }
- ]);
+ addMessage('Finally, which AI model would you like to use?', 'bot', getModelChips(selectedLang));
} else {
// Handle other languages with coming soon message
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 }
]);
}
@@ -475,7 +490,11 @@
}
}
- if (selectedModel) {
+ const allowedModels = getModelChips(conversationState.selections.programmingLanguage).map(m => m.value);
+ if (selectedModel && !allowedModels.includes(selectedModel)) {
+ addMessage("Anthropic isn't supported for JavaScript — its API isn't OpenAI-compatible. Please choose from:", 'bot',
+ getModelChips(conversationState.selections.programmingLanguage));
+ } else if (selectedModel) {
conversationState.selections.llmModel = selectedModel;
const config = CONFIG.models[selectedModel];
@@ -487,11 +506,8 @@
generateAndDisplayCode();
}, 1500);
} else {
- addMessage("I didn't recognize that model. Please choose from:", 'bot', [
- { value: 'openai', label: '🤖 OpenAI (GPT-4)' },
- { value: 'anthropic', label: '🧠 Anthropic (Claude)' },
- { value: 'llama3', label: '🦙 Llama 3' }
- ]);
+ addMessage("I didn't recognize that model. Please choose from:", 'bot',
+ getModelChips(conversationState.selections.programmingLanguage));
}
}
@@ -520,8 +536,8 @@
java: '.java',
csharp: '.cs'
};
- const base = window.HUGO_BASEURL || '';
- const filename = `${base}code/agent-templates/${formData.programmingLanguage}/${formData.agentType}_agent${fileExtensions[formData.programmingLanguage]}`;
+ const templateBase = (window.AGENT_TEMPLATE_BASE || '/code/agent-templates').replace(/\/$/, '');
+ const filename = `${templateBase}/${formData.programmingLanguage}/${formData.agentType}_agent${fileExtensions[formData.programmingLanguage]}`;
return loadTemplateFile(filename, formData) || genericTemplates[formData.programmingLanguage](formData);
}
@@ -606,10 +622,13 @@ require('dotenv').config();
class ${formData.agentName.replace(/\s+/g, '')} {
constructor() {
this.redisClient = redis.createClient({
- host: process.env.REDIS_HOST || 'localhost',
- port: process.env.REDIS_PORT || 6379
+ socket: {
+ host: process.env.REDIS_HOST || 'localhost',
+ port: parseInt(process.env.REDIS_PORT || '6379'),
+ },
+ password: process.env.REDIS_PASSWORD,
});
- this.llmApiKey = process.env.${formData.llmModel.toUpperCase()}_API_KEY;
+ this.llmApiKey = process.env.LLM_API_KEY || 'no-key-needed';
}
async processQuery(query) {
@@ -743,28 +762,16 @@ public class ${formData.agentName.replace(/\s+/g, '')}
elements.codeSection.dataset.code = code;
elements.codeSection.dataset.filename = getFilename(formData);
- // Handle Jupyter button state based on selected model
+ // Jupyter notebook support is not yet available; keep the button disabled
const tryJupyterBtn = document.getElementById('try-jupyter-btn');
if (tryJupyterBtn) {
- if (formData.llmModel !== 'openai') {
- // Disable and grey out the button for non-OpenAI models
- tryJupyterBtn.disabled = true;
- tryJupyterBtn.style.backgroundColor = '#B8B8B8';
- tryJupyterBtn.style.color = '#4B4F58';
- tryJupyterBtn.style.borderColor = '#B8B8B8';
- tryJupyterBtn.style.cursor = 'not-allowed';
- tryJupyterBtn.style.opacity = '1';
- tryJupyterBtn.title = 'Coming soon';
- } else {
- // Enable the button for OpenAI models
- tryJupyterBtn.disabled = false;
- tryJupyterBtn.style.backgroundColor = '';
- tryJupyterBtn.style.color = '';
- tryJupyterBtn.style.borderColor = '';
- tryJupyterBtn.style.cursor = 'pointer';
- tryJupyterBtn.style.opacity = '1';
- tryJupyterBtn.title = 'Try your agent in a Jupyter notebook';
- }
+ tryJupyterBtn.disabled = true;
+ tryJupyterBtn.style.backgroundColor = '#B8B8B8';
+ tryJupyterBtn.style.color = '#4B4F58';
+ tryJupyterBtn.style.borderColor = '#B8B8B8';
+ tryJupyterBtn.style.cursor = 'not-allowed';
+ tryJupyterBtn.style.opacity = '1';
+ tryJupyterBtn.title = 'Coming soon';
}
// Attach event listeners to code action buttons now that they're visible