diff --git a/src/routes/generate.js b/src/routes/generate.js index d0c0397..e991975 100644 --- a/src/routes/generate.js +++ b/src/routes/generate.js @@ -42,4 +42,73 @@ router.get('/:id', async (req, res) => { } }); +// POST /api/generate/transform — синхронная трансформация поста (короче/длиннее/улучшить) +router.post('/transform', async (req, res) => { + try { + const { channelId, originalPost, action } = req.body; + const userId = parseInt(req.headers['x-user-id']) || null; + if (!channelId || !originalPost || !action) { + return res.status(400).json({ error: 'channelId, originalPost, action required' }); + } + const channel = await channelsSvc.getChannel(userId, channelId); + if (!channel) return res.status(404).json({ error: 'Channel not found' }); + + const ai = require('../services/ai'); + const result = await ai.transformPost(channel, { originalPost, action }); + res.json(result); + } catch (err) { + console.error('[Route] POST /transform', err); + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/generate/post-image — синхронная генерация картинки к посту +router.post('/post-image', async (req, res) => { + try { + const { channelId, post } = req.body; + const userId = parseInt(req.headers['x-user-id']) || null; + if (!channelId || !post) return res.status(400).json({ error: 'channelId and post required' }); + + const channel = await channelsSvc.getChannel(userId, channelId); + if (!channel) return res.status(404).json({ error: 'Channel not found' }); + + const { generatePostImage } = require('../services/postImages'); + const result = await generatePostImage({ post, channel, style: channel.style || {} }); + res.json(result); + } catch (err) { + console.error('[Route] POST /post-image', err); + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/generate/image-styles — список доступных стилей картинок +router.get('/image-styles', async (_, res) => { + const { IMAGE_STYLES, IMAGE_PALETTES } = require('../services/postImages'); + res.json({ + styles: Object.entries(IMAGE_STYLES).map(([v, s]) => ({ value: v, label: s.label })), + palettes: Object.keys(IMAGE_PALETTES).map(v => ({ + value: v, + label: { auto: 'Авто', dark: 'Тёмная', light: 'Светлая', warm: 'Тёплая', cool: 'Холодная', mono: 'Монохром', vibrant: 'Яркая' }[v] || v + })), + }); +}); + +// POST /api/generate/topics-ideas — синхронные идеи тем для канала +router.post('/topics-ideas', async (req, res) => { + try { + const { channelId, count = 7 } = req.body; + const userId = parseInt(req.headers['x-user-id']) || null; + if (!channelId) return res.status(400).json({ error: 'channelId required' }); + const channel = await channelsSvc.getChannel(userId, channelId); + if (!channel) return res.status(404).json({ error: 'Channel not found' }); + + const ai = require('../services/ai'); + const result = await ai.generateTopics(channel, count); + res.json(result); + } catch (err) { + console.error('[Route] POST /topics-ideas', err); + res.status(500).json({ error: err.message }); + } +}); + module.exports = router; diff --git a/src/services/ai.js b/src/services/ai.js index c5f6729..7b350ef 100644 --- a/src/services/ai.js +++ b/src/services/ai.js @@ -105,6 +105,40 @@ async function generatePost(channel, opts = {}) { }; } +/** + * Трансформировать существующий пост (короче/длиннее/иначе по тону и т.д.) + */ +async function transformPost(channel, opts = {}) { + const { originalPost, action } = opts; + if (!originalPost || !action) throw new Error('originalPost and action required'); + + const ACTIONS = { + shorter: 'Сократи этот пост в 2 раза, сохрани суть и стиль. Убери воду, но сохрани живость.', + longer: 'Расширь этот пост в 1.5-2 раза. Добавь пример, деталь, или конкретику — но не воды.', + bolder: 'Сделай пост более дерзким и провокационным. Острее формулировки, смелее заходы.', + softer: 'Смягчи тон поста. Менее категорично, больше доброжелательности.', + addCta: 'Добавь в конце поста сильный призыв к действию (1-2 предложения), уместный для этой темы.', + forVk: 'Адаптируй пост для ВКонтакте: убери Markdown (звёздочки, заголовки), сделай абзацы короче, добавь 2-3 уместных эмодзи в начале параграфов.', + forTwitter: 'Сделай из этого поста твит до 280 символов — самую суть, без воды.', + improve: 'Улучши пост: убери AI-штампы и канцелярит, сделай живее, конкретнее. Сохрани тему и основные идеи.', + }; + + const instruction = ACTIONS[action]; + if (!instruction) throw new Error(`Unknown action: ${action}`); + + const systemPrompt = pb.buildPostSystemPrompt(channel) + `\n\nТЫ РЕДАКТИРУЕШЬ СУЩЕСТВУЮЩИЙ ПОСТ. Сохрани основную идею и стиль канала.`; + const userPrompt = `Вот текущая версия поста:\n\n${originalPost}\n\n---\n\nЗадача: ${instruction}\n\nВерни только переработанный пост, без комментариев.`; + + const res = await chat( + config.ai.models.post, + systemPrompt, + userPrompt, + { maxTokens: 1500, temperature: 0.8 } + ); + + return { content: res.text, usage: res.usage }; +} + /** * Сгенерировать идеи тем для канала. */ @@ -153,6 +187,7 @@ module.exports = { chat, image, generatePost, + transformPost, generateTopics, generateArticle, }; diff --git a/src/services/postImages.js b/src/services/postImages.js new file mode 100644 index 0000000..6d281d1 --- /dev/null +++ b/src/services/postImages.js @@ -0,0 +1,125 @@ +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); +const config = require('../config'); + +const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; + +let sharp = null; +try { sharp = require('sharp'); } catch {} + +if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true }); + +/** + * Стили картинок к постам — словарь для перевода в промпт. + */ +const IMAGE_STYLES = { + 'realistic-photo': { + label: 'Реалистичное фото', + prompt: 'photorealistic, high-quality photography, natural lighting, professional camera shot, sharp focus, realistic textures', + }, + 'flat-illustration': { + label: 'Плоская иллюстрация', + prompt: 'flat vector illustration, clean geometric shapes, modern editorial style, smooth gradients, minimal', + }, + '3d-render': { + label: '3D рендер', + prompt: '3D render, soft studio lighting, isometric perspective, smooth surfaces, modern materials, Pixar-like quality', + }, + 'cartoon': { + label: 'Мультяшный', + prompt: 'cartoon illustration, bold outlines, vibrant colors, expressive characters, comic book style', + }, + 'minimal': { + label: 'Минимализм', + prompt: 'extremely minimalist composition, single focal element, lots of negative space, monochrome or duotone', + }, + 'abstract': { + label: 'Абстракция', + prompt: 'abstract artwork, layered shapes, conceptual composition, no literal objects, mood and texture focused', + }, + 'sketch': { + label: 'Эскиз / скетч', + prompt: 'hand-drawn sketch style, pencil and ink, loose lines, notebook page aesthetic', + }, + 'cyberpunk': { + label: 'Киберпанк', + prompt: 'cyberpunk aesthetic, neon lights, futuristic city, dark atmospheric mood, blade runner vibe', + }, +}; + +const IMAGE_PALETTES = { + 'auto': '', + 'dark': 'dark color palette, deep blues, blacks, subtle highlights', + 'light': 'light color palette, soft whites, pastels, airy mood', + 'warm': 'warm color palette, oranges, reds, golden tones', + 'cool': 'cool color palette, blues, teals, purples', + 'mono': 'monochromatic palette, single hue with shades', + 'vibrant': 'vibrant saturated colors, high energy palette', +}; + +/** + * Генерирует картинку к посту через GPT-5 /v1/responses + image_generation. + */ +async function generatePostImage({ post, channel, style = {} }) { + const imageStyle = IMAGE_STYLES[style.image_style] || IMAGE_STYLES['flat-illustration']; + const palette = style.image_custom_colors + ? `custom brand palette: ${style.image_custom_colors}` + : IMAGE_PALETTES[style.image_palette] || ''; + + // Извлекаем суть поста для промпта (первые 250 символов) + const postExcerpt = post.replace(/[#*_`>]/g, '').slice(0, 250); + + const prompt = `Editorial illustration for a social media post. Topic essence: "${postExcerpt}" + +Style: ${imageStyle.prompt}. +${palette ? `Color palette: ${palette}.` : ''} +Channel context: ${channel.niche || channel.name}. + +Composition: 16:9 wide format, balanced, suitable for social media. +Strictly: no text, no letters, no logos, no faces of real people.`; + + const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2'; + const wrappedInput = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`; + + const res = await axios.post( + `${config.ai.baseUrl}/responses`, + { + model, + input: wrappedInput, + tools: [{ type: 'image_generation' }], + tool_choice: { type: 'image_generation' }, + }, + { + headers: { Authorization: `Bearer ${config.ai.imageApiKey}` }, + timeout: 300_000, + } + ); + + const output = res.data?.output || []; + const imgCall = output.find(o => o.type === 'image_generation_call'); + if (!imgCall?.result) throw new Error('No image generated'); + + const bytes = Buffer.from(imgCall.result, 'base64'); + const tsKey = `post-${channel.id}-${Date.now()}`; + const ext = imgCall.output_format || 'png'; + + // Оптимизация через sharp если есть + let publicUrl; + if (sharp) { + const webpName = `${tsKey}.webp`; + await sharp(bytes) + .resize(1600, null, { withoutEnlargement: true }) + .webp({ quality: 84 }) + .toFile(path.join(UPLOADS_DIR, webpName)); + publicUrl = `/uploads/${webpName}`; + } else { + const name = `${tsKey}.${ext}`; + fs.writeFileSync(path.join(UPLOADS_DIR, name), bytes); + publicUrl = `/uploads/${name}`; + } + + return { url: publicUrl, style: style.image_style, palette: style.image_palette }; +} + +module.exports = { generatePostImage, IMAGE_STYLES, IMAGE_PALETTES };