diff --git a/src/routes/articles.js b/src/routes/articles.js index f3f4540..5ade5ec 100644 --- a/src/routes/articles.js +++ b/src/routes/articles.js @@ -116,9 +116,9 @@ router.get('/id/:id', async (req, res) => { // POST /api/articles/generate router.post('/generate', async (req, res) => { 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' }); - const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish: autoPub, category }); + const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish: autoPub, category, customPrompt }); // Hook: автопубликация в каналы if (article && article.status === 'published') { autoPublish.scheduleForArticle(article.id).catch(err => { diff --git a/src/services/ai.js b/src/services/ai.js index 8551a36..2bc111e 100644 --- a/src/services/ai.js +++ b/src/services/ai.js @@ -229,11 +229,17 @@ async function generateTopics(channel, count = 5) { * 2. Критика + переписывание (если useEditPass=true, по умолчанию включено) */ 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'); + // customPrompt (из UI при ручном запуске) перебивает channel.ai_style_prompt + const stylePrompt = customPrompt || channel?.ai_style_prompt || null; + 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( diff --git a/src/services/aiUsage.js b/src/services/aiUsage.js index 3b4222c..99cac8c 100644 --- a/src/services/aiUsage.js +++ b/src/services/aiUsage.js @@ -37,6 +37,17 @@ const IMAGE_PRICES_USD = { '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) { if (!url) return 'unknown'; if (url.includes('aiprimetech')) return 'aiprimetech'; @@ -61,6 +72,14 @@ async function computeCostRub({ requestType, model, promptTokens, completionToke return +(costUsd * markup * usdRubRate).toFixed(4); } 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]; if (perImage === undefined) return null; return +((imageCount || 1) * perImage * markup * usdRubRate).toFixed(4); diff --git a/src/services/articles.js b/src/services/articles.js index 9546cf6..033baf9 100644 --- a/src/services/articles.js +++ b/src/services/articles.js @@ -72,7 +72,7 @@ async function getAllTags() { * Генерирует и сохраняет статью. * @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 const { rows: jobRows } = await query( `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; // вытаскиваю title (первый H1 или первая строка) и excerpt diff --git a/src/services/channels.js b/src/services/channels.js index d2af1e4..a9f2a04 100644 --- a/src/services/channels.js +++ b/src/services/channels.js @@ -116,7 +116,7 @@ async function updateChannel(channelId, userId, data) { if (Object.keys(channelFields).length) { const fields = ['name', 'tg_channel_id', 'tg_username', 'bot_token', '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); if (updates.length) { const setClauses = updates.map((f, i) => `${f}=$${i + 1}`).join(', '); diff --git a/src/services/postImages.js b/src/services/postImages.js index 03bbb2a..96c1fb7 100644 --- a/src/services/postImages.js +++ b/src/services/postImages.js @@ -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.`; // 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() { 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`, { model, 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' }, }, { headers: { Authorization: `Bearer ${config.ai.routeraiApiKey}` }, timeout: 120_000 }); const imgCall = (res.data?.output || []).find(o => o.type === 'image_generation_call'); 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'); } 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; } }