forked from admin/zeropost-engine
feat: custom prompt for articles + HD image quality per channel
- ai.js: generateArticle принимает customPrompt (от юзера) или channel.ai_style_prompt - articles.js + routes/articles.js: проброс customPrompt через цепочку - postImages.js: channel.image_quality='hd' → gpt-5.4-image-2+medium, иначе gpt-5-image-mini+low - aiUsage.js: правильные цены routerai (RUB/token), gpt-5-image-mini и gpt-5.4-image-2 - channels.js: updateChannel сохраняет ai_style_prompt и image_quality - DB: channels.ai_style_prompt TEXT, channels.image_quality VARCHAR(16) DEFAULT standard
This commit is contained in:
@@ -116,9 +116,9 @@ router.get('/id/:id', async (req, res) => {
|
|||||||
// POST /api/articles/generate
|
// POST /api/articles/generate
|
||||||
router.post('/generate', async (req, res) => {
|
router.post('/generate', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { topic, keywords = [], tags = [], autoPublish: autoPub = true, category = 'ai-tools' } = req.body;
|
const { topic, keywords = [], tags = [], autoPublish: autoPub = true, category = 'ai-tools', customPrompt } = req.body;
|
||||||
if (!topic) return res.status(400).json({ error: 'topic is required' });
|
if (!topic) return res.status(400).json({ error: 'topic is required' });
|
||||||
const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish: autoPub, category });
|
const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish: autoPub, category, customPrompt });
|
||||||
// Hook: автопубликация в каналы
|
// Hook: автопубликация в каналы
|
||||||
if (article && article.status === 'published') {
|
if (article && article.status === 'published') {
|
||||||
autoPublish.scheduleForArticle(article.id).catch(err => {
|
autoPublish.scheduleForArticle(article.id).catch(err => {
|
||||||
|
|||||||
+8
-2
@@ -229,11 +229,17 @@ async function generateTopics(channel, count = 5) {
|
|||||||
* 2. Критика + переписывание (если useEditPass=true, по умолчанию включено)
|
* 2. Критика + переписывание (если useEditPass=true, по умолчанию включено)
|
||||||
*/
|
*/
|
||||||
async function generateArticle(channel, opts = {}) {
|
async function generateArticle(channel, opts = {}) {
|
||||||
const { topic, keywords = [], useEditPass = true } = opts;
|
const { topic, keywords = [], useEditPass = true, customPrompt } = opts;
|
||||||
if (!topic) throw new Error('topic is required');
|
if (!topic) throw new Error('topic is required');
|
||||||
|
|
||||||
|
// customPrompt (из UI при ручном запуске) перебивает channel.ai_style_prompt
|
||||||
|
const stylePrompt = customPrompt || channel?.ai_style_prompt || null;
|
||||||
|
|
||||||
const systemPrompt = pb.buildArticleSystemPrompt(channel, keywords);
|
const systemPrompt = pb.buildArticleSystemPrompt(channel, keywords);
|
||||||
const userPrompt = `Напиши статью на тему: "${topic}"`;
|
let userPrompt = `Напиши статью на тему: "${topic}"`;
|
||||||
|
if (stylePrompt) {
|
||||||
|
userPrompt += `\n\n---\nДОПОЛНИТЕЛЬНЫЕ ИНСТРУКЦИИ (выполнить обязательно):\n${stylePrompt.trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
// === Первый проход — драфт ===
|
// === Первый проход — драфт ===
|
||||||
const draft = await chat(
|
const draft = await chat(
|
||||||
|
|||||||
@@ -37,6 +37,17 @@ const IMAGE_PRICES_USD = {
|
|||||||
'dall-e-2': 0.02,
|
'dall-e-2': 0.02,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Цены routerai уже в рублях (RUB/token за image_output).
|
||||||
|
// Считаем per-image по кол-ву токенов в зависимости от модели.
|
||||||
|
// Токенов: low≈272, medium≈1056, high≈4160 для 1024×1024.
|
||||||
|
const ROUTERAI_IMAGE_RUB_PER_TOKEN = {
|
||||||
|
'openai/gpt-5-image-mini': 0.000747,
|
||||||
|
'openai/gpt-5.4-image-2': 0.002800,
|
||||||
|
'openai/gpt-5-image': 0.003733,
|
||||||
|
};
|
||||||
|
// Дефолтное кол-во токенов: low (standard) и medium (hd)
|
||||||
|
const ROUTERAI_TOKENS = { low: 272, medium: 1056, high: 4160, standard: 272, hd: 1056 };
|
||||||
|
|
||||||
function providerFromBaseUrl(url) {
|
function providerFromBaseUrl(url) {
|
||||||
if (!url) return 'unknown';
|
if (!url) return 'unknown';
|
||||||
if (url.includes('aiprimetech')) return 'aiprimetech';
|
if (url.includes('aiprimetech')) return 'aiprimetech';
|
||||||
@@ -61,6 +72,14 @@ async function computeCostRub({ requestType, model, promptTokens, completionToke
|
|||||||
return +(costUsd * markup * usdRubRate).toFixed(4);
|
return +(costUsd * markup * usdRubRate).toFixed(4);
|
||||||
}
|
}
|
||||||
if (requestType === 'image' || requestType === 'image_via_responses') {
|
if (requestType === 'image' || requestType === 'image_via_responses') {
|
||||||
|
// Routerai — цены в рублях, считаем по токенам
|
||||||
|
const routeraiRate = ROUTERAI_IMAGE_RUB_PER_TOKEN[model];
|
||||||
|
if (routeraiRate !== undefined) {
|
||||||
|
const quality = o?.quality || 'low'; // 'low' | 'medium' | 'hd' | 'standard'
|
||||||
|
const tokens = ROUTERAI_TOKENS[quality] || 272;
|
||||||
|
const inputCost = ((o?.promptTokens || 100) / 1_000_000) * 233; // ₽233/1M input токенов
|
||||||
|
return +(tokens * routeraiRate + inputCost).toFixed(4);
|
||||||
|
}
|
||||||
const perImage = IMAGE_PRICES_USD[model];
|
const perImage = IMAGE_PRICES_USD[model];
|
||||||
if (perImage === undefined) return null;
|
if (perImage === undefined) return null;
|
||||||
return +((imageCount || 1) * perImage * markup * usdRubRate).toFixed(4);
|
return +((imageCount || 1) * perImage * markup * usdRubRate).toFixed(4);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ async function getAllTags() {
|
|||||||
* Генерирует и сохраняет статью.
|
* Генерирует и сохраняет статью.
|
||||||
* @param {object} opts - { topic, keywords, tags, autoPublish }
|
* @param {object} opts - { topic, keywords, tags, autoPublish }
|
||||||
*/
|
*/
|
||||||
async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPublish = true, category = 'ai-tools' }) {
|
async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPublish = true, category = 'ai-tools', customPrompt }) {
|
||||||
// job
|
// job
|
||||||
const { rows: jobRows } = await query(
|
const { rows: jobRows } = await query(
|
||||||
`INSERT INTO generation_jobs (type, topic, status) VALUES ('article',$1,'processing') RETURNING id`,
|
`INSERT INTO generation_jobs (type, topic, status) VALUES ('article',$1,'processing') RETURNING id`,
|
||||||
@@ -118,7 +118,7 @@ async function generateAndSaveArticle({ topic, keywords = [], tags = [], autoPub
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const articleRes = await ai.generateArticle(blogChannel, { topic, keywords });
|
const articleRes = await ai.generateArticle(blogChannel, { topic, keywords, customPrompt });
|
||||||
const content = articleRes.content;
|
const content = articleRes.content;
|
||||||
|
|
||||||
// вытаскиваю title (первый H1 или первая строка) и excerpt
|
// вытаскиваю title (первый H1 или первая строка) и excerpt
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ async function updateChannel(channelId, userId, data) {
|
|||||||
if (Object.keys(channelFields).length) {
|
if (Object.keys(channelFields).length) {
|
||||||
const fields = ['name', 'tg_channel_id', 'tg_username', 'bot_token',
|
const fields = ['name', 'tg_channel_id', 'tg_username', 'bot_token',
|
||||||
'niche', 'audience', 'goal', 'language', 'region', 'is_active',
|
'niche', 'audience', 'goal', 'language', 'region', 'is_active',
|
||||||
'vk_access_token'];
|
'vk_access_token', 'ai_style_prompt', 'image_quality'];
|
||||||
const updates = fields.filter(f => channelFields[f] !== undefined);
|
const updates = fields.filter(f => channelFields[f] !== undefined);
|
||||||
if (updates.length) {
|
if (updates.length) {
|
||||||
const setClauses = updates.map((f, i) => `${f}=$${i + 1}`).join(', ');
|
const setClauses = updates.map((f, i) => `${f}=$${i + 1}`).join(', ');
|
||||||
|
|||||||
@@ -86,7 +86,13 @@ Composition: 16:9 wide format, balanced, suitable for social media.
|
|||||||
Strictly: no text, no letters, no logos, no faces of real people.`;
|
Strictly: no text, no letters, no logos, no faces of real people.`;
|
||||||
|
|
||||||
// RouterAI /responses (primary) → Nyxos /images/generations (fallback)
|
// RouterAI /responses (primary) → Nyxos /images/generations (fallback)
|
||||||
const model = config.ai.routeraiImageModel || 'openai/gpt-5-image-mini';
|
// standard: gpt-5-image-mini + low quality (₽0.20/картинка)
|
||||||
|
// hd: gpt-5.4-image-2 + medium quality (₽2.96/картинка) — для текста на картинках
|
||||||
|
const isHD = channel.image_quality === 'hd';
|
||||||
|
const model = isHD
|
||||||
|
? 'openai/gpt-5.4-image-2'
|
||||||
|
: (config.ai.routeraiImageModel || 'openai/gpt-5-image-mini');
|
||||||
|
const imgQuality = isHD ? 'medium' : 'low';
|
||||||
|
|
||||||
async function tryRouterAI() {
|
async function tryRouterAI() {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
@@ -94,15 +100,15 @@ Strictly: no text, no letters, no logos, no faces of real people.`;
|
|||||||
const res = await axios.post(`${config.ai.routeraiBaseUrl}/responses`, {
|
const res = await axios.post(`${config.ai.routeraiBaseUrl}/responses`, {
|
||||||
model,
|
model,
|
||||||
input: `Use the image_generation tool to create this illustration. Only call the tool, no text.\n\n${prompt.slice(0, 3000)}`,
|
input: `Use the image_generation tool to create this illustration. Only call the tool, no text.\n\n${prompt.slice(0, 3000)}`,
|
||||||
tools: [{ type: 'image_generation', quality: 'low' }],
|
tools: [{ type: 'image_generation', quality: imgQuality }],
|
||||||
tool_choice: { type: 'image_generation' },
|
tool_choice: { type: 'image_generation' },
|
||||||
}, { headers: { Authorization: `Bearer ${config.ai.routeraiApiKey}` }, timeout: 120_000 });
|
}, { headers: { Authorization: `Bearer ${config.ai.routeraiApiKey}` }, timeout: 120_000 });
|
||||||
const imgCall = (res.data?.output || []).find(o => o.type === 'image_generation_call');
|
const imgCall = (res.data?.output || []).find(o => o.type === 'image_generation_call');
|
||||||
if (!imgCall?.result) throw new Error('No image in RouterAI response');
|
if (!imgCall?.result) throw new Error('No image in RouterAI response');
|
||||||
aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, imageCount: 1, meta: { channel_id: channel.id }, durationMs: Date.now()-started, succeeded: true }).catch(() => {});
|
aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, quality: imgQuality, imageCount: 1, meta: { channel_id: channel.id }, durationMs: Date.now()-started, succeeded: true }).catch(() => {});
|
||||||
return Buffer.from(imgCall.result, 'base64');
|
return Buffer.from(imgCall.result, 'base64');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, imageCount: 1, meta: { channel_id: channel.id }, durationMs: Date.now()-started, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500) }).catch(() => {});
|
aiUsage.log({ provider: 'routerai', requestType: 'image_via_responses', model, quality: imgQuality, imageCount: 1, meta: { channel_id: channel.id }, durationMs: Date.now()-started, succeeded: false, errorMessage: (err.response?.data?.error?.message || err.message || '').slice(0, 500) }).catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user