@gv-sh/specgen-server
Version:
SpecGen Server - API for Speculative Fiction Generator
526 lines (462 loc) • 19.4 kB
JavaScript
// services/aiService.js
const axios = require('axios');
const { Buffer } = require('buffer');
const settingsService = require('./settingsService');
/**
* Service for interacting with OpenAI API
*/
class AIService {
constructor() {
this.apiKey = globalThis.process?.env?.OPENAI_API_KEY;
this.baseUrl = 'https://api.openai.com/v1';
this.chatCompletionUrl = `${this.baseUrl}/chat/completions`;
this.imageGenerationUrl = `${this.baseUrl}/images/generations`;
if (!this.apiKey) {
console.error('ERROR: OPENAI_API_KEY not set in environment variables');
}
}
/**
* Generate content based on provided parameters and type (fiction, image, or combined)
* @param {Object} parameters - User-selected parameters for generation
* @param {String} type - Type of content to generate ('fiction', 'image', or 'combined')
* @param {Number} year - Optional year for the story setting
* @returns {Promise<Object>} - Generated content from OpenAI
*/
async generateContent(parameters, type = 'fiction', year = null) {
try {
// Call appropriate generation method based on type
if (type === 'fiction') {
return this.generateFiction(parameters, year);
} else if (type === 'image') {
return this.generateImage(parameters, year);
} else if (type === 'combined') {
return this.generateCombined(parameters, year);
} else {
throw new Error(`Unsupported generation type: ${type}`);
}
} catch (error) {
console.error(`Error generating ${type}:`, error.response ? error.response.data : error.message);
return {
success: false,
error: error.response ? error.response.data.error.message : error.message
};
}
}
/**
* Generate fiction content based on provided parameters
* @param {Object} parameters - User-selected parameters for fiction generation
* @param {Number} year - Optional year for story setting
* @returns {Promise<Object>} - Generated fiction content from OpenAI
*/
async generateFiction(parameters, year = null) {
try {
// Get model settings
const model = await settingsService.getSetting('ai.models.fiction', 'gpt-4o-mini');
const temperature = await settingsService.getSetting('ai.parameters.fiction.temperature', 0.8);
const maxTokens = await settingsService.getSetting('ai.parameters.fiction.max_tokens', 1000);
// Format parameters into a clean markdown prompt, including year
const prompt = await this.formatFictionPrompt(parameters, year);
// Get system prompt from settings
const systemPrompt = await settingsService.getSetting(
'ai.parameters.fiction.system_prompt',
"You are a speculative fiction generator that creates compelling, imaginative stories based on the parameters provided by the user."
);
// Call OpenAI API
const response = await axios.post(
this.chatCompletionUrl,
{
model: model,
messages: [
{
role: "system",
content: systemPrompt
},
{
role: "user",
content: prompt
}
],
temperature: temperature,
max_tokens: maxTokens
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
}
);
// Log success response
console.log('OpenAI response received for fiction:',
response.data?.choices ? 'Success' : 'No choices in response',
'Model:', response.data?.model || 'unknown'
);
// Extract the generated content and title from response
const generatedContent = response.data.choices[0].message.content;
const extractedTitle = this.extractTitleFromContent(generatedContent);
// Extract or use provided year
const storyYear = year || this.extractYearFromContent(generatedContent);
return {
success: true,
content: generatedContent,
title: extractedTitle,
year: storyYear,
metadata: {
model: response.data.model,
tokens: response.data.usage.total_tokens
}
};
} catch (error) {
console.error('Error calling OpenAI text generation API:',
error.response ? JSON.stringify(error.response.data, null, 2) : error.message
);
return {
success: false,
error: error.response ? error.response.data.error.message : error.message
};
}
}
/**
* Generate image based on provided parameters and optional generated text
* @param {Object} parameters - User-selected parameters for image generation
* @param {Number} year - Optional year to include in the image concept
* @param {String} generatedText - Optional generated text to use for creating a more coherent image
* @returns {Promise<Object>} - Generated image URL from OpenAI
*/
async generateImage(parameters, year = null, generatedText = null) {
try {
// Get model settings
const model = await settingsService.getSetting('ai.models.image', 'dall-e-3');
const size = await settingsService.getSetting('ai.parameters.image.size', '1024x1024');
const quality = await settingsService.getSetting('ai.parameters.image.quality', 'standard');
// Format parameters into a prompt for image generation, including year and generated text if provided
const prompt = this.formatImagePrompt(parameters, year, generatedText);
// Call OpenAI API for image generation
const response = await axios.post(
this.imageGenerationUrl,
{
model: model,
prompt: prompt,
n: 1,
size: size,
quality: quality,
response_format: "b64_json"
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
}
);
// Extract the generated image data from response
return {
success: true,
imageData: Buffer.from(response.data.data[0].b64_json, 'base64'),
year: year, // Pass through the year
title: null, // Return null title to allow controller to use provided title
metadata: {
model: model,
prompt: prompt
}
};
} catch (error) {
console.error('Error calling OpenAI image generation API:', error.response ? error.response.data : error.message);
return {
success: false,
error: error.response ? error.response.data.error.message : error.message
};
}
}
/**
* Format user parameters into a clean markdown prompt for OpenAI text generation
* @param {Object} parameters - User-selected parameters
* @param {Number} year - Optional year for story setting
* @returns {String} - Formatted prompt
*/
async formatFictionPrompt(parameters, year = null) {
// Get default story length from settings
const defaultStoryLength = await settingsService.getSetting(
'ai.parameters.fiction.default_story_length',
500
);
// Extract the story length parameter if present
let storyLength = defaultStoryLength;
// Start with a simple prompt
let prompt = "Write a speculative fiction story with the following elements:\n\n";
// If year is provided, add it to the prompt
if (year) {
prompt += `Set this story in the year ${year}.\n\n`;
}
// Format each parameter in a simple list
Object.entries(parameters).forEach(([categoryName, categoryParams]) => {
// Add category name
prompt += `${categoryName}:\n`;
// Add each parameter as a bullet point
Object.entries(categoryParams).forEach(([paramName, paramValue]) => {
if (paramName.toLowerCase().includes('length') && typeof paramValue === 'number') {
storyLength = paramValue;
} else if (Array.isArray(paramValue)) {
prompt += `- ${paramName}: ${paramValue.join(', ')}\n`;
} else if (typeof paramValue === 'boolean') {
prompt += `- ${paramName}: ${paramValue ? 'Yes' : 'No'}\n`;
} else {
prompt += `- ${paramName}: ${paramValue}\n`;
}
});
prompt += '\n';
});
// Add instructions for title format and story length
prompt += "IMPORTANT: Begin your response with a title in this format: **Title: Your Story Title Here**\n";
prompt += `The story should be approximately ${storyLength} words long. Make it creative, engaging, and with a clear beginning, middle, and end.`;
return prompt;
}
/**
* Format user parameters into a prompt for DALL-E image generation
* @param {Object} parameters - User-selected parameters
* @param {Number} year - Optional year to include in the image concept
* @param {String} generatedText - Optional generated text to use for creating a more coherent image
* @returns {String} - Formatted prompt
*/
formatImagePrompt(parameters, year = null, generatedText = null) {
let prompt = "Create a detailed, visually striking image";
// If we have generated text, use it to create a more coherent image
if (generatedText) {
// Extract key visual elements from the generated text
const visualElements = this.extractVisualElementsFromText(generatedText);
if (visualElements.length > 0) {
prompt += ` depicting the following scene: ${visualElements.join(', ')}`;
}
// Add context from the generated text
prompt += "\n\nThis image should complement the following story:\n";
// Use the first 500 characters of the story for context
const storyExcerpt = generatedText.substring(0, 500) + (generatedText.length > 500 ? '...' : '');
prompt += `"${storyExcerpt}"\n\n`;
} else {
// Fallback to parameter-based prompt when no text is provided
prompt += " with the following elements:\n\n";
}
// If year is provided, add it to the prompt
if (year) {
prompt += `Set in the year ${year}. `;
}
// Collect all parameter values for the image prompt
Object.entries(parameters).forEach(([categoryName, categoryParams]) => {
prompt += `${categoryName}: `;
const paramValues = [];
// Add each parameter and its value
Object.entries(categoryParams).forEach(([paramName, paramValue]) => {
// Handle different parameter types
if (Array.isArray(paramValue)) {
// For multi-select parameters (checkboxes)
paramValues.push(`${paramName}: ${paramValue.join(', ')}`);
} else if (typeof paramValue === 'boolean') {
// For toggle parameters
if (paramValue) {
paramValues.push(paramName);
}
} else {
// For other parameter types (dropdown, radio, slider)
paramValues.push(`${paramName}: ${paramValue}`);
}
});
prompt += paramValues.join(', ');
prompt += '.\n';
});
// Get prompt suffix from settings
const defaultSuffix = "Use high-quality, photorealistic rendering with attention to lighting, detail, and composition. The image should be visually cohesive and striking.";
prompt += `\n${defaultSuffix}`;
return prompt;
}
/**
* Extract visual elements from generated text to create a more coherent image
* @param {String} text - Generated story text
* @returns {Array<String>} - Array of visual elements
*/
extractVisualElementsFromText(text) {
if (!text) return [];
const visualElements = [];
// Remove the title if present
const cleanText = text.replace(/\*\*Title:.*?\*\*/, '').trim();
// Extract characters - improved patterns with better action words
const characterPatterns = [
/(Dr\.|Professor|Captain|Agent|Detective)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/gi,
/([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(stood|walked|ran|sat|looked|gazed|stared|stepped)/gi
];
for (const pattern of characterPatterns) {
const matches = cleanText.match(pattern);
if (matches) {
matches.slice(0, 3).forEach(match => {
// Clean up the match to get just the name
let cleaned = match.replace(/\s+(stood|walked|ran|sat|looked|gazed|stared|stepped).*$/i, '').trim();
// For patterns with titles, keep the full title + name
if (cleaned.match(/^(Dr\.|Professor|Captain|Agent|Detective)/)) {
visualElements.push(cleaned);
} else {
// For action-based patterns, extract just the name before the action
const nameMatch = match.match(/^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/);
if (nameMatch) {
visualElements.push(nameMatch[1]);
}
}
});
}
}
// Extract locations and settings
const locationPatterns = [
/(in|at|on|through)\s+(the\s+)?([A-Z][a-z\s]{3,30}(?:city|planet|station|facility|dome|colony|ship|chamber|laboratory|castle|forest|mountain|desert|ocean|space))/gi,
/(starship|spaceship|vessel|craft|vehicle)\s+([A-Z][a-z]+)/gi,
/(colony|city|station|outpost|facility)\s+([A-Z][a-z\s]+)/gi
];
for (const pattern of locationPatterns) {
const matches = cleanText.match(pattern);
if (matches) {
matches.slice(0, 2).forEach(match => {
const location = match.replace(/^(in|at|on|through)\s+(the\s+)?/i, '').trim();
if (location.length > 3 && location.length < 50) {
visualElements.push(location);
}
});
}
}
// Extract objects and technology
// Pattern 1: adjective + noun combinations
let matches = cleanText.match(/(advanced|alien|ancient|glowing|metallic|crystalline)\s+(scanner|device|weapon|tool|helmet|suit|console|terminal|reactor|portal|gateway|chamber|throne|altar|artifact)/gi);
if (matches) {
matches.forEach(match => {
visualElements.push(match.trim());
});
}
// Pattern 2: standalone tech objects
matches = cleanText.match(/\b(scanner|device|weapon|tool|helmet|suit|console|terminal|reactor|portal|gateway|chamber|throne|altar|artifact)\b/gi);
if (matches) {
matches.forEach(match => {
visualElements.push(match.trim());
});
}
// Extract atmospheric elements
// Pattern 1: action + atmospheric combinations
matches = cleanText.match(/(glittering|shimmering|glowing|pulsing|swirling|drifting)\s+(purple\s+mist|mist|fog|clouds|dust|air)/gi);
if (matches) {
matches.forEach(match => {
visualElements.push(match.trim());
});
}
// Pattern 2: color + atmospheric combinations
matches = cleanText.match(/(red|blue|green|golden|silver|purple|crimson)\s+(light|glow|aurora|mist|dust|sky)/gi);
if (matches) {
matches.forEach(match => {
visualElements.push(match.trim());
});
}
// Pattern 3: standalone atmospheric elements
matches = cleanText.match(/\b(chamber|storm|clouds|mist|fog|aurora|lightning)\b/gi);
if (matches) {
matches.forEach(match => {
visualElements.push(match.trim());
});
}
// Remove duplicates (case-insensitive) and limit to most important elements
const uniqueElements = [];
const seenElements = new Set();
for (const element of visualElements) {
const lowerElement = element.toLowerCase();
// Additional check to avoid partial matches within longer names
let isDuplicate = false;
for (const seenElement of seenElements) {
if (seenElement === lowerElement ||
(lowerElement.includes(seenElement) && seenElement.length > 5) ||
(seenElement.includes(lowerElement) && lowerElement.length > 5)) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
seenElements.add(lowerElement);
uniqueElements.push(element);
}
}
return uniqueElements.slice(0, 5); // Limit to 5 key visual elements
}
/**
* Generate both fiction and image content based on the same parameters
* Now generates image based on the generated fiction text for better coherence
* @param {Object} parameters - User-selected parameters for generation
* @param {Number} year - Optional year for story setting
* @returns {Promise<Object>} - Generated fiction and image content from OpenAI
*/
async generateCombined(parameters, year = null) {
try {
// First generate the fiction content
const fictionResult = await this.generateFiction(parameters, year);
if (!fictionResult.success) {
return fictionResult; // Return early if fiction generation failed
}
// Then generate the image based on the generated text and parameters
// This creates a more coherent image that complements the story
const imageResult = await this.generateImage(
parameters,
year || fictionResult.year,
fictionResult.content // Pass the generated text to create a coherent image
);
if (!imageResult.success) {
return imageResult; // Return early if image generation failed
}
// Combine the results
return {
success: true,
content: fictionResult.content,
title: fictionResult.title,
year: year || fictionResult.year,
imageData: imageResult.imageData,
metadata: {
fiction: fictionResult.metadata,
image: imageResult.metadata
}
};
} catch (error) {
console.error('Error in combined generation:',
error.response ? JSON.stringify(error.response.data, null, 2) : error.message
);
return {
success: false,
error: error.response ? error.response.data.error.message : error.message
};
}
}
/**
* Extract title from generated content
* @param {String} content - Generated story content
* @returns {String} - Extracted title or default title
*/
extractTitleFromContent(content) {
if (!content) return "Untitled Story";
// Look for title in the format: **Title: Story Title**
const titleMatch = content.match(/\*\*Title:\s*(.*?)\*\*/);
if (titleMatch && titleMatch[1]) {
return titleMatch[1].trim();
}
// Fallback: use first line as title if it's reasonably short
const firstLine = content.split('\n')[0].trim();
if (firstLine.length <= 60) {
return firstLine;
}
return "Untitled Story";
}
/**
* Extract year from content if not provided in parameters
* @param {String} content - Generated story content
* @returns {Number|null} - Extracted year or null
*/
extractYearFromContent(content) {
if (!content) return null;
// Look for year mentions in the first few paragraphs
const yearRegex = /\b(20\d{2}|21\d{2}|22\d{2})\b/;
const yearMatch = content.match(yearRegex);
if (yearMatch && yearMatch[1]) {
return parseInt(yearMatch[1]);
}
return null;
}
}
module.exports = new AIService();