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..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..f96f905231 --- /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 <= 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() { + // 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) > 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-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 != 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)}/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..fcc7950b2c 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,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); } @@ -606,10 +621,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 +761,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