312 lines
11 KiB
JavaScript
312 lines
11 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { OpenAI } = require('openai');
|
|
const { GoogleGenAI } = require('@google/genai');
|
|
const { Progress } = require('../utils/progress');
|
|
const { color } = require('../utils/colors');
|
|
|
|
/**
|
|
* Enhanced AI provider management with caching and fallbacks
|
|
*/
|
|
class AIProviderManager {
|
|
constructor(config = {}) {
|
|
this.config = config;
|
|
this.cacheFile = path.join(
|
|
process.env.HOME || process.env.USERPROFILE || process.cwd(),
|
|
'.gims', 'cache.json'
|
|
);
|
|
}
|
|
|
|
getDefaultModel(provider) {
|
|
const defaults = {
|
|
'gemini': 'gemini-2.0-flash',
|
|
'openai': 'gpt-4o-mini',
|
|
'groq': 'llama-3.3-70b-versatile'
|
|
};
|
|
return defaults[provider] || '';
|
|
}
|
|
|
|
getCacheKey(prompt, options) {
|
|
const crypto = require('crypto');
|
|
const key = JSON.stringify({ prompt: prompt.substring(0, 1000), options });
|
|
return crypto.createHash('md5').update(key).digest('hex');
|
|
}
|
|
|
|
getFromCache(cacheKey) {
|
|
if (!this.config.cacheEnabled) return null;
|
|
try {
|
|
if (!fs.existsSync(this.cacheFile)) return null;
|
|
const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf8'));
|
|
return data[cacheKey] || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
setCache(cacheKey, result, usedLocal = false) {
|
|
if (!this.config.cacheEnabled) return;
|
|
try {
|
|
const dir = path.dirname(this.cacheFile);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
let data = {};
|
|
if (fs.existsSync(this.cacheFile)) {
|
|
try { data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf8')); } catch {}
|
|
}
|
|
const keys = Object.keys(data);
|
|
if (keys.length >= 100) {
|
|
const oldest = keys.sort((a, b) => (data[a].timestamp || 0) - (data[b].timestamp || 0));
|
|
oldest.slice(0, keys.length - 99).forEach(k => delete data[k]);
|
|
}
|
|
data[cacheKey] = { result, usedLocal, timestamp: Date.now() };
|
|
fs.writeFileSync(this.cacheFile, JSON.stringify(data));
|
|
} catch {}
|
|
}
|
|
|
|
async generateWithProvider(provider, prompt, options = {}) {
|
|
const { model = '' } = options;
|
|
|
|
try {
|
|
switch (provider) {
|
|
case 'gemini':
|
|
return await this.generateWithGemini(prompt, model || this.getDefaultModel('gemini'), options);
|
|
case 'openai':
|
|
return await this.generateWithOpenAI(prompt, model || this.getDefaultModel('openai'), options);
|
|
case 'groq':
|
|
return await this.generateWithGroq(prompt, model || this.getDefaultModel('groq'), options);
|
|
default:
|
|
throw new Error(`Unknown provider: ${provider}`);
|
|
}
|
|
} catch (error) {
|
|
const msg = error.message || '';
|
|
if (msg.includes('429') || /rate.?limit/i.test(msg)) {
|
|
throw new Error(`${provider} rate limit hit — try again in a moment`);
|
|
}
|
|
if (msg.includes('401') || /invalid.api.key|api_key/i.test(msg)) {
|
|
throw new Error(`${provider} API key invalid — run: g setup --api-key ${provider}`);
|
|
}
|
|
throw new Error(`${provider} generation failed: ${msg}`);
|
|
}
|
|
}
|
|
|
|
async generateWithGemini(prompt, model, options) {
|
|
const genai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
|
const actualModel = model || this.getDefaultModel('gemini');
|
|
const response = await genai.models.generateContent({
|
|
model: actualModel,
|
|
contents: prompt,
|
|
});
|
|
|
|
// Handle @google/genai v1.5+ raw response structure
|
|
if (response && response.candidates && response.candidates.length > 0) {
|
|
const parts = response.candidates[0].content.parts;
|
|
return parts.map(p => p.text || '').join('').trim();
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
async generateWithOpenAI(prompt, model, options) {
|
|
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
const actualModel = model || this.getDefaultModel('openai');
|
|
const response = await openai.chat.completions.create({
|
|
model: actualModel,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
temperature: options.temperature || 0.3,
|
|
max_tokens: options.maxTokens || 200,
|
|
});
|
|
return (response.choices[0]?.message?.content || '').trim();
|
|
}
|
|
|
|
async generateWithGroq(prompt, model, options) {
|
|
const groq = new OpenAI({
|
|
apiKey: process.env.GROQ_API_KEY,
|
|
baseURL: process.env.GROQ_BASE_URL || 'https://api.groq.com/openai/v1'
|
|
});
|
|
const actualModel = model || this.getDefaultModel('groq');
|
|
const response = await groq.chat.completions.create({
|
|
model: actualModel,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
temperature: options.temperature || 0.3,
|
|
max_tokens: options.maxTokens || 200,
|
|
});
|
|
return (response.choices[0]?.message?.content || '').trim();
|
|
}
|
|
|
|
async generateCommitMessage(diff, options = {}) {
|
|
const {
|
|
provider: preferredProvider = this.config.provider || 'auto',
|
|
conventional = this.config.conventional || false,
|
|
body = false,
|
|
verbose = false
|
|
} = options;
|
|
|
|
// Truncate oversized diffs before sending to AI
|
|
const maxDiffSize = this.config.maxDiffSize || 100000;
|
|
const truncatedDiff = diff.length > maxDiffSize
|
|
? diff.substring(0, maxDiffSize) + '\n... (diff truncated for AI)'
|
|
: diff;
|
|
|
|
// Check cache first
|
|
const cacheKey = this.getCacheKey(truncatedDiff, { conventional, body });
|
|
const cached = this.getFromCache(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < 3600000) { // 1 hour cache
|
|
if (verbose) Progress.info('Using cached result');
|
|
return { message: cached.result, usedLocal: cached.usedLocal || false };
|
|
}
|
|
|
|
const providerChain = this.buildProviderChain(preferredProvider);
|
|
|
|
for (const provider of providerChain) {
|
|
try {
|
|
if (provider === 'local') {
|
|
const result = await this.generateLocalHeuristic(truncatedDiff, options);
|
|
this.setCache(cacheKey, result, true);
|
|
return { message: result, usedLocal: true };
|
|
}
|
|
|
|
const prompt = this.buildPrompt(truncatedDiff, { conventional, body });
|
|
const result = await this.generateWithProvider(provider, prompt, options);
|
|
const cleaned = this.cleanCommitMessage(result, { body });
|
|
|
|
this.setCache(cacheKey, cleaned, false);
|
|
return { message: cleaned, usedLocal: false };
|
|
|
|
} catch (error) {
|
|
if (verbose) Progress.warning(`${provider} failed: ${error.message}`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Final fallback
|
|
const fallback = 'Update project files';
|
|
this.setCache(cacheKey, fallback, true);
|
|
return { message: fallback, usedLocal: true };
|
|
}
|
|
|
|
buildProviderChain(preferred) {
|
|
const available = [];
|
|
|
|
// First, try the preferred provider if its key is available
|
|
if (preferred !== 'auto' && preferred !== 'none') {
|
|
if (preferred === 'gemini' && process.env.GEMINI_API_KEY) available.push('gemini');
|
|
else if (preferred === 'openai' && process.env.OPENAI_API_KEY) available.push('openai');
|
|
else if (preferred === 'groq' && process.env.GROQ_API_KEY) available.push('groq');
|
|
}
|
|
|
|
// Then add all other available providers as fallbacks
|
|
if (process.env.GEMINI_API_KEY && !available.includes('gemini')) available.push('gemini');
|
|
if (process.env.OPENAI_API_KEY && !available.includes('openai')) available.push('openai');
|
|
if (process.env.GROQ_API_KEY && !available.includes('groq')) available.push('groq');
|
|
|
|
// Local heuristics as final fallback
|
|
available.push('local');
|
|
return available;
|
|
}
|
|
|
|
buildPrompt(diff, options) {
|
|
const { conventional, body } = options;
|
|
|
|
const style = conventional
|
|
? 'Use Conventional Commits format (e.g., feat:, fix:, chore:) for the subject.'
|
|
: 'Subject must be a single short line.';
|
|
|
|
const bodyInstr = body
|
|
? 'Provide a short subject line followed by an optional body separated by a blank line.'
|
|
: 'Return only a short subject line without extra quotes.';
|
|
|
|
return `Write a concise git commit message for these changes:\n${diff}\n\n${style} ${bodyInstr}`;
|
|
}
|
|
|
|
cleanCommitMessage(message, options = {}) {
|
|
if (!message) return 'Update project code';
|
|
|
|
// Remove markdown formatting
|
|
let cleaned = message
|
|
.replace(/```[\s\S]*?```/g, '')
|
|
.replace(/`([^`]+)`/g, '$1')
|
|
.replace(/^\s*[-*+]\s*/gm, '')
|
|
.replace(/^\s*\d+\.\s*/gm, '')
|
|
.replace(/^\s*#+\s*/gm, '')
|
|
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
.replace(/\*(.*?)\*/g, '$1')
|
|
.replace(/[\u{1F300}-\u{1FAFF}]/gu, '')
|
|
.replace(/[\t\r]+/g, ' ')
|
|
.trim();
|
|
|
|
const lines = cleaned.split('\n').map(l => l.trim()).filter(Boolean);
|
|
let subject = (lines[0] || '').replace(/\s{2,}/g, ' ').replace(/[\s:,.!;]+$/g, '').trim();
|
|
|
|
if (subject.length === 0) subject = 'Update project code';
|
|
// No length restriction - allow AI to generate full commit messages
|
|
|
|
if (!options.body) return subject;
|
|
|
|
const bodyLines = lines.slice(1).filter(l => l.length > 0);
|
|
const bodyText = bodyLines.join('\n').trim();
|
|
return bodyText ? `${subject}\n\n${bodyText}` : subject;
|
|
}
|
|
|
|
async generateLocalHeuristic(diff, options) {
|
|
// This would need access to git status - simplified version
|
|
const { conventional = false } = options;
|
|
|
|
// Analyze diff for patterns
|
|
const lines = diff.split('\n');
|
|
const additions = lines.filter(l => l.startsWith('+')).length;
|
|
const deletions = lines.filter(l => l.startsWith('-')).length;
|
|
const files = (diff.match(/diff --git/g) || []).length;
|
|
|
|
let type = 'chore';
|
|
let subject = 'update files';
|
|
|
|
if (additions > deletions * 2) {
|
|
type = 'feat';
|
|
subject = files === 1 ? 'add new functionality' : `add features to ${files} files`;
|
|
} else if (deletions > additions * 2) {
|
|
type = 'chore';
|
|
subject = files === 1 ? 'remove unused code' : `clean up ${files} files`;
|
|
} else if (diff.includes('test') || diff.includes('spec')) {
|
|
type = 'test';
|
|
subject = 'update tests';
|
|
} else if (diff.includes('README') || diff.includes('doc')) {
|
|
type = 'docs';
|
|
subject = 'update documentation';
|
|
}
|
|
|
|
return conventional ? `${type}: ${subject}` : subject.charAt(0).toUpperCase() + subject.slice(1);
|
|
}
|
|
|
|
async generateMultipleSuggestions(diff, options = {}, count = 3) {
|
|
const suggestions = [];
|
|
const baseOptions = { ...options };
|
|
|
|
// Generate different styles
|
|
const variants = [
|
|
{ ...baseOptions, conventional: false },
|
|
{ ...baseOptions, conventional: true },
|
|
{ ...baseOptions, conventional: true, body: true }
|
|
];
|
|
|
|
for (let i = 0; i < Math.min(count, variants.length); i++) {
|
|
try {
|
|
const result = await this.generateCommitMessage(diff, variants[i]);
|
|
// Extract message string from result object
|
|
const message = result.message || result;
|
|
if (message && !suggestions.includes(message)) {
|
|
suggestions.push(message);
|
|
}
|
|
} catch (error) {
|
|
// Skip failed generations
|
|
}
|
|
}
|
|
|
|
// Ensure we have at least one suggestion
|
|
if (suggestions.length === 0) {
|
|
suggestions.push('Update project files');
|
|
}
|
|
|
|
return suggestions;
|
|
}
|
|
}
|
|
|
|
module.exports = { AIProviderManager }; |