From 3bf88f8d935e80113dba6864233e6fb4f8c943ca Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Wed, 27 May 2026 13:02:32 -0700 Subject: [PATCH 1/4] Add Node.js agent templates and unlock JavaScript in agent builder Add conversational and recommendation agent templates using node-redis v4 with Redis Search for vector-based message history and movie indexing. Conversational agent uses hybrid recent+semantic context retrieval, runtime embedding dimension validation, and a clear note on Redis version requirements. Recommendation agent parses genres from MovieLens CSV format into Redis TAGs, validates LLM query params against an explicit allowlist, skips dataset reload if the index is already warm, and filters genres in Redis rather than JS. Fix template URL to use root-relative path so local templates load in dev. Update agent builder to support JavaScript alongside Python. Co-Authored-By: Claude Sonnet 4.6 --- content/develop/ai/agent-builder/_index.md | 2 +- .../javascript/conversational_agent.js | 251 ++++++++++++ .../javascript/recommendation_agent.js | 363 ++++++++++++++++++ static/js/agent-builder.js | 14 +- 4 files changed, 622 insertions(+), 8 deletions(-) create mode 100644 static/code/agent-templates/javascript/conversational_agent.js create mode 100644 static/code/agent-templates/javascript/recommendation_agent.js 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); } From 0d0e1e2039bbe92fa470059ef71c1b761d914ba7 Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Tue, 9 Jun 2026 12:56:33 -0700 Subject: [PATCH 2/4] Fix PR review feedback on Node.js agent templates and builder - Fix deduplication bug: _getRecentMessages now zips Redis keys with json.mGet results so m._key is the actual key, not undefined - Fix recent-window arithmetic: lTrim and lRange now use RECENT_WINDOW * 2 (user + assistant per turn) instead of * 4 - Fix ft.dropIndex error-string match: replaced brittle catch with ft.info existence check to handle Redis 8 error wording - Fix num_docs field name: ft.info returns num_docs not numDocs, so _indexExists was always returning false and reloading data on every start - Fix Llama3 requiring LLM_API_KEY: default to 'no-key-needed' instead of throwing so local Ollama users don't need a dummy value - Hide Anthropic from JS model selector: JS templates use the OpenAI SDK which is not compatible with api.anthropic.com - Fix Jupyter button: always disabled (feature not yet available) - Fix generic JS fallback: use LLM_API_KEY and node-redis v4 socket shape Co-Authored-By: Claude Sonnet 4.6 --- .../javascript/conversational_agent.js | 17 ++-- .../javascript/recommendation_agent.js | 15 ++-- static/js/agent-builder.js | 86 ++++++++++--------- 3 files changed, 58 insertions(+), 60 deletions(-) diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js index daaf314426..853f50bd7d 100644 --- a/static/code/agent-templates/javascript/conversational_agent.js +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -39,8 +39,8 @@ class ConversationalAgent { 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'); + // 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}'; @@ -137,18 +137,17 @@ class ConversationalAgent { // 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); + await this.redisClient.lTrim(RECENT_KEY(this.sessionName), -RECENT_WINDOW * 2, -1); } async _getRecentMessages() { - const keys = await this.redisClient.lRange(RECENT_KEY(this.sessionName), 0, -1); + 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 docs - .filter(Boolean) - .flatMap((d) => d) - .filter(Boolean) - .map((m) => ({ role: m.role, content: m.content, _key: m._key })); + 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) { diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index fdc6eb5824..3771399b50 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -98,8 +98,8 @@ function validateQueryParams(raw) { class RecommendationAgent { constructor() { - this.llmApiKey = process.env.LLM_API_KEY; - if (!this.llmApiKey) throw new Error('LLM_API_KEY environment variable is required'); + // 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}'; @@ -133,7 +133,7 @@ class RecommendationAgent { async _indexExists() { try { const info = await this.redisClient.ft.info(INDEX_NAME); - return parseInt(info.numDocs) > 0; + return parseInt(info.num_docs) > 0; } catch { return false; } @@ -207,12 +207,9 @@ class RecommendationAgent { 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; - } + // Drop existing index if present. + const indexExists = await this.redisClient.ft.info(INDEX_NAME).then(() => true).catch(() => false); + if (indexExists) await this.redisClient.ft.dropIndex(INDEX_NAME); await this.redisClient.ft.create( INDEX_NAME, diff --git a/static/js/agent-builder.js b/static/js/agent-builder.js index 098a404687..962382c7e0 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; @@ -435,11 +453,7 @@ // 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]; @@ -488,11 +502,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)); } } @@ -606,10 +617,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 +757,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 From 92ed78b0435f58ebbc46560ff999ff15ff4f9e98 Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Tue, 9 Jun 2026 14:13:12 -0700 Subject: [PATCH 3/4] Fix additional PR review feedback on Node.js agent templates - Fix multi-word genre TAG queries: escape spaces as \\ so 'Science Fiction' matches as a single token rather than two separate terms in RediSearch - Fix reindex leaving stale data: use DD flag on ft.dropIndex to delete movie documents along with the index on reload - Fix Anthropic bypass: processModelSelection now checks allowedModels so typing 'anthropic' or 'claude' while on JavaScript is rejected with a clear message rather than generating broken code - Fix context messages out of order: sort combined recent + semantic results by key (which encodes timestamp) before passing to the LLM - Fix trimmed messages never deleted: evict and delete JSON documents for keys that will fall off the recent window before each lTrim call Co-Authored-By: Claude Sonnet 4.6 --- .../javascript/conversational_agent.js | 17 ++++++++++++++--- .../javascript/recommendation_agent.js | 13 +++++++++---- static/js/agent-builder.js | 6 +++++- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js index 853f50bd7d..8fd9054cbb 100644 --- a/static/code/agent-templates/javascript/conversational_agent.js +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -135,7 +135,15 @@ class ConversationalAgent { embedding, // stored as JSON array of floats, required for JSON vector index }); - // Track insertion order for recent-turn retrieval + // 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); } @@ -176,11 +184,14 @@ class ConversationalAgent { this._getSemanticMessages(userInput).catch(() => []), ]); - // Deduplicate by key, preserving recent turns first + // 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].map(({ role, content }) => ({ role, content })); + 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) { diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index 3771399b50..6fa09499e9 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -207,9 +207,9 @@ class RecommendationAgent { console.log(`Processed ${merged.length} movies`); - // Drop existing index if present. + // 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); + if (indexExists) await this.redisClient.ft.dropIndex(INDEX_NAME, { DD: true }); await this.redisClient.ft.create( INDEX_NAME, @@ -278,8 +278,13 @@ Return only valid JSON with no explanation or markdown.`; 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('|'); + // Redis TAG filter: match any of the requested genres. + // Spaces inside multi-word tags (e.g. "Science Fiction") must be backslash-escaped + // so RediSearch treats them as a single token rather than two separate terms. + const tagList = params.genres + .map((g) => g.replace(/[^a-zA-Z0-9 ]/g, '').trim().replace(/ +/g, '\\ ')) + .filter(Boolean) + .join('|'); if (tagList) filterParts.push(`@genres:{${tagList}}`); } diff --git a/static/js/agent-builder.js b/static/js/agent-builder.js index 962382c7e0..fcc7950b2c 100644 --- a/static/js/agent-builder.js +++ b/static/js/agent-builder.js @@ -490,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]; From ec2044f27ed366e4ebc14611f023429a230dc12b Mon Sep 17 00:00:00 2001 From: mich-elle-luna Date: Tue, 9 Jun 2026 14:33:41 -0700 Subject: [PATCH 4/4] Fix genre filter, zero minRating, and Llama embedding provider - Fix hyphenated genres stripped from TAG query: allow hyphens through the sanitizer and backslash-escape them alongside spaces so Film-Noir matches the stored value instead of becoming FilmNoir - Fix zero minRating silently dropped: use != null instead of truthy check so a minimum rating of 0 is included in the filter query - Fix Llama/Ollama breaking semantic history: add separate embedder client (EMBEDDING_API_KEY / EMBEDDING_API_BASE_URL) that defaults to the LLM values so Ollama users just set EMBEDDING_MODEL=nomic-embed-text with no extra config, matching the pattern used in the RAG templates Co-Authored-By: Claude Sonnet 4.6 --- .../javascript/conversational_agent.js | 21 ++++++++++++++++--- .../javascript/recommendation_agent.js | 13 +++++++----- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/static/code/agent-templates/javascript/conversational_agent.js b/static/code/agent-templates/javascript/conversational_agent.js index 8fd9054cbb..a4283b7905 100644 --- a/static/code/agent-templates/javascript/conversational_agent.js +++ b/static/code/agent-templates/javascript/conversational_agent.js @@ -14,10 +14,15 @@ * 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) + * + * 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(); @@ -46,6 +51,16 @@ class ConversationalAgent { 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; } @@ -97,7 +112,7 @@ class ConversationalAgent { } async _embed(text) { - const response = await this.openai.embeddings.create({ + const response = await this.embedder.embeddings.create({ model: EMBEDDING_MODEL, input: text, }); diff --git a/static/code/agent-templates/javascript/recommendation_agent.js b/static/code/agent-templates/javascript/recommendation_agent.js index 6fa09499e9..f96f905231 100644 --- a/static/code/agent-templates/javascript/recommendation_agent.js +++ b/static/code/agent-templates/javascript/recommendation_agent.js @@ -274,15 +274,18 @@ Return only valid JSON with no explanation or markdown.`; // 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.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. - // Spaces inside multi-word tags (e.g. "Science Fiction") must be backslash-escaped - // so RediSearch treats them as a single token rather than two separate terms. + // 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, '\\ ')) + .map((g) => g.replace(/[^a-zA-Z0-9 \-]/g, '').trim() + .replace(/-/g, '\\-') + .replace(/ +/g, '\\ ')) .filter(Boolean) .join('|'); if (tagList) filterParts.push(`@genres:{${tagList}}`);