266 lines
8.5 KiB
JavaScript
266 lines
8.5 KiB
JavaScript
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const https = require('https');
|
||
|
|
const http = require('http');
|
||
|
|
const { getDb } = require('../db');
|
||
|
|
|
||
|
|
const AI_CONFIG_PATH = path.join(__dirname, '..', 'data', 'ai_config.json');
|
||
|
|
|
||
|
|
const PROVIDER_PRESETS = {
|
||
|
|
openrouter: {
|
||
|
|
label: 'OpenRouter',
|
||
|
|
base_url: 'https://openrouter.ai/api/v1',
|
||
|
|
models: ['qwen/qwen3-coder:free', 'meta-llama/llama-3.3-70b-instruct:free', 'google/gemma-3-27b-it:free', 'anthropic/claude-3.5-sonnet', 'openai/gpt-4o-mini']
|
||
|
|
},
|
||
|
|
openai: {
|
||
|
|
label: 'OpenAI',
|
||
|
|
base_url: 'https://api.openai.com/v1',
|
||
|
|
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1-mini']
|
||
|
|
},
|
||
|
|
anthropic: {
|
||
|
|
label: 'Anthropic',
|
||
|
|
base_url: 'https://api.anthropic.com/v1',
|
||
|
|
models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229']
|
||
|
|
},
|
||
|
|
deepseek: {
|
||
|
|
label: 'DeepSeek',
|
||
|
|
base_url: 'https://api.deepseek.com/v1',
|
||
|
|
models: ['deepseek-chat', 'deepseek-reasoner']
|
||
|
|
},
|
||
|
|
groq: {
|
||
|
|
label: 'Groq',
|
||
|
|
base_url: 'https://api.groq.com/openai/v1',
|
||
|
|
models: ['llama-3.3-70b-versatile', 'mixtral-8x7b-32768']
|
||
|
|
},
|
||
|
|
ollama: {
|
||
|
|
label: 'Ollama (local)',
|
||
|
|
base_url: 'http://localhost:11434/v1',
|
||
|
|
models: ['llama3.2', 'mistral', 'codellama', 'qwen2.5']
|
||
|
|
},
|
||
|
|
azure: {
|
||
|
|
label: 'Azure OpenAI',
|
||
|
|
base_url: '',
|
||
|
|
models: ['gpt-4o', 'gpt-4o-mini']
|
||
|
|
},
|
||
|
|
custom: {
|
||
|
|
label: 'Custom (OpenAI-compatible)',
|
||
|
|
base_url: '',
|
||
|
|
models: []
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
function initAiSchema() {
|
||
|
|
const db = getDb();
|
||
|
|
db.exec(`
|
||
|
|
CREATE TABLE IF NOT EXISTS ai_providers (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
provider_type TEXT NOT NULL DEFAULT 'openrouter',
|
||
|
|
api_key TEXT DEFAULT '',
|
||
|
|
base_url TEXT DEFAULT '',
|
||
|
|
default_model TEXT DEFAULT '',
|
||
|
|
is_active INTEGER DEFAULT 1,
|
||
|
|
is_default INTEGER DEFAULT 0,
|
||
|
|
extra_headers TEXT DEFAULT '{}',
|
||
|
|
created_at TEXT DEFAULT (datetime('now')),
|
||
|
|
updated_at TEXT DEFAULT (datetime('now'))
|
||
|
|
);
|
||
|
|
CREATE TABLE IF NOT EXISTS integrations (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
name TEXT DEFAULT '',
|
||
|
|
api_key TEXT DEFAULT '',
|
||
|
|
webhook_url TEXT DEFAULT '',
|
||
|
|
config TEXT DEFAULT '{}',
|
||
|
|
status TEXT DEFAULT 'inactive',
|
||
|
|
created_at TEXT DEFAULT (datetime('now'))
|
||
|
|
);
|
||
|
|
`);
|
||
|
|
|
||
|
|
const count = db.prepare('SELECT COUNT(*) as c FROM ai_providers').get().c;
|
||
|
|
if (count === 0 && process.env.DEEPSEEK_API_KEY) {
|
||
|
|
db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_default)
|
||
|
|
VALUES (?, ?, ?, ?, ?, 1)`).run(
|
||
|
|
'OpenRouter Default', 'openrouter', process.env.DEEPSEEK_API_KEY,
|
||
|
|
'https://openrouter.ai/api/v1', 'qwen/qwen3-coder:free'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let _aiSchemaReady = false;
|
||
|
|
function ensureAiSchema() {
|
||
|
|
if (_aiSchemaReady) return;
|
||
|
|
initAiSchema();
|
||
|
|
_aiSchemaReady = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getProviders() {
|
||
|
|
ensureAiSchema();
|
||
|
|
return getDb().prepare('SELECT id, name, provider_type, base_url, default_model, is_active, is_default, created_at, updated_at FROM ai_providers ORDER BY is_default DESC, name').all();
|
||
|
|
}
|
||
|
|
|
||
|
|
function getProvider(id) {
|
||
|
|
ensureAiSchema();
|
||
|
|
return getDb().prepare('SELECT * FROM ai_providers WHERE id = ?').get(id);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDefaultProvider() {
|
||
|
|
ensureAiSchema();
|
||
|
|
let p = getDb().prepare('SELECT * FROM ai_providers WHERE is_default = 1 AND is_active = 1 LIMIT 1').get();
|
||
|
|
if (!p) p = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1 ORDER BY id LIMIT 1').get();
|
||
|
|
return p;
|
||
|
|
}
|
||
|
|
|
||
|
|
function saveProvider(data) {
|
||
|
|
ensureAiSchema();
|
||
|
|
const db = getDb();
|
||
|
|
if (data.is_default) {
|
||
|
|
db.prepare('UPDATE ai_providers SET is_default = 0').run();
|
||
|
|
}
|
||
|
|
if (data.id) {
|
||
|
|
db.prepare(`UPDATE ai_providers SET name=?, provider_type=?, api_key=?, base_url=?, default_model=?,
|
||
|
|
is_active=?, is_default=?, updated_at=datetime('now') WHERE id=?`).run(
|
||
|
|
data.name, data.provider_type, data.api_key || '', data.base_url || '',
|
||
|
|
data.default_model || '', data.is_active ? 1 : 0, data.is_default ? 1 : 0, data.id
|
||
|
|
);
|
||
|
|
syncAiConfigFile();
|
||
|
|
return data.id;
|
||
|
|
}
|
||
|
|
const r = db.prepare(`INSERT INTO ai_providers (name, provider_type, api_key, base_url, default_model, is_active, is_default)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
||
|
|
data.name, data.provider_type, data.api_key || '', data.base_url || '',
|
||
|
|
data.default_model || '', data.is_active !== false ? 1 : 0, data.is_default ? 1 : 0
|
||
|
|
);
|
||
|
|
syncAiConfigFile();
|
||
|
|
return r.lastInsertRowid;
|
||
|
|
}
|
||
|
|
|
||
|
|
function deleteProvider(id) {
|
||
|
|
ensureAiSchema();
|
||
|
|
getDb().prepare('DELETE FROM ai_providers WHERE id = ?').run(id);
|
||
|
|
syncAiConfigFile();
|
||
|
|
}
|
||
|
|
|
||
|
|
function syncAiConfigFile() {
|
||
|
|
ensureAiSchema();
|
||
|
|
const providers = getDb().prepare('SELECT * FROM ai_providers WHERE is_active = 1').all();
|
||
|
|
const defaultProvider = providers.find(p => p.is_default) || providers[0] || null;
|
||
|
|
const config = {
|
||
|
|
default_provider_id: defaultProvider?.id || null,
|
||
|
|
providers: providers.map(p => ({
|
||
|
|
id: p.id,
|
||
|
|
name: p.name,
|
||
|
|
provider_type: p.provider_type,
|
||
|
|
api_key: p.api_key,
|
||
|
|
base_url: p.base_url || PROVIDER_PRESETS[p.provider_type]?.base_url || '',
|
||
|
|
default_model: p.default_model,
|
||
|
|
is_default: !!p.is_default
|
||
|
|
}))
|
||
|
|
};
|
||
|
|
const dir = path.dirname(AI_CONFIG_PATH);
|
||
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
|
|
fs.writeFileSync(AI_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||
|
|
|
||
|
|
const leadgenConfig = {
|
||
|
|
api_key: defaultProvider?.api_key || process.env.DEEPSEEK_API_KEY || '',
|
||
|
|
base_url: defaultProvider?.base_url || 'https://openrouter.ai/api/v1',
|
||
|
|
model: defaultProvider?.default_model || 'qwen/qwen3-coder:free',
|
||
|
|
provider_type: defaultProvider?.provider_type || 'openrouter'
|
||
|
|
};
|
||
|
|
fs.writeFileSync(path.join(dir, 'leadgen_config.json'), JSON.stringify(leadgenConfig, null, 2));
|
||
|
|
}
|
||
|
|
|
||
|
|
function maskKey(key) {
|
||
|
|
if (!key || key.length < 8) return key ? '••••••••' : '';
|
||
|
|
return key.slice(0, 6) + '••••' + key.slice(-4);
|
||
|
|
}
|
||
|
|
|
||
|
|
function chatCompletion(messages, options = {}) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const provider = options.providerId
|
||
|
|
? getProvider(options.providerId)
|
||
|
|
: getDefaultProvider();
|
||
|
|
if (!provider || !provider.api_key) {
|
||
|
|
return reject(new Error('Geen actieve AI provider geconfigureerd. Ga naar /ai om een provider toe te voegen.'));
|
||
|
|
}
|
||
|
|
|
||
|
|
const preset = PROVIDER_PRESETS[provider.provider_type] || PROVIDER_PRESETS.custom;
|
||
|
|
const baseUrl = provider.base_url || preset.base_url;
|
||
|
|
const model = options.model || provider.default_model || preset.models[0];
|
||
|
|
|
||
|
|
let url;
|
||
|
|
try {
|
||
|
|
url = new URL('/chat/completions', baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
|
||
|
|
} catch (e) {
|
||
|
|
return reject(new Error('Ongeldige base URL: ' + baseUrl));
|
||
|
|
}
|
||
|
|
|
||
|
|
const body = JSON.stringify({
|
||
|
|
model,
|
||
|
|
messages,
|
||
|
|
temperature: options.temperature ?? 0.7,
|
||
|
|
max_tokens: options.max_tokens ?? 4096
|
||
|
|
});
|
||
|
|
|
||
|
|
const isHttps = url.protocol === 'https:';
|
||
|
|
const lib = isHttps ? https : http;
|
||
|
|
const headers = {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'Content-Length': Buffer.byteLength(body)
|
||
|
|
};
|
||
|
|
|
||
|
|
if (provider.provider_type === 'anthropic') {
|
||
|
|
headers['x-api-key'] = provider.api_key;
|
||
|
|
headers['anthropic-version'] = '2023-06-01';
|
||
|
|
} else {
|
||
|
|
headers['Authorization'] = `Bearer ${provider.api_key}`;
|
||
|
|
}
|
||
|
|
if (provider.provider_type === 'openrouter') {
|
||
|
|
headers['HTTP-Referer'] = process.env.BASE_URL || 'http://localhost:3000';
|
||
|
|
headers['X-Title'] = 'Mek-Tech Platform';
|
||
|
|
}
|
||
|
|
|
||
|
|
const reqOpts = {
|
||
|
|
hostname: url.hostname,
|
||
|
|
port: url.port || (isHttps ? 443 : 80),
|
||
|
|
path: url.pathname,
|
||
|
|
method: 'POST',
|
||
|
|
headers,
|
||
|
|
timeout: 120000
|
||
|
|
};
|
||
|
|
|
||
|
|
const req = lib.request(reqOpts, (res) => {
|
||
|
|
let data = '';
|
||
|
|
res.on('data', c => { data += c; });
|
||
|
|
res.on('end', () => {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(data);
|
||
|
|
if (res.statusCode >= 400) {
|
||
|
|
reject(new Error(parsed.error?.message || parsed.message || data.slice(0, 200)));
|
||
|
|
} else {
|
||
|
|
resolve(parsed);
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
reject(new Error('AI response parse error: ' + data.slice(0, 200)));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
req.on('error', reject);
|
||
|
|
req.on('timeout', () => { req.destroy(); reject(new Error('AI request timeout')); });
|
||
|
|
req.write(body);
|
||
|
|
req.end();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
PROVIDER_PRESETS,
|
||
|
|
initAiSchema: ensureAiSchema,
|
||
|
|
getProviders,
|
||
|
|
getProvider,
|
||
|
|
getDefaultProvider,
|
||
|
|
saveProvider,
|
||
|
|
deleteProvider,
|
||
|
|
syncAiConfigFile,
|
||
|
|
maskKey,
|
||
|
|
chatCompletion
|
||
|
|
};
|