feat: transformPost (7 actions), post image generation with style/palette, topics ideas endpoint
This commit is contained in:
@@ -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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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,
|
chat,
|
||||||
image,
|
image,
|
||||||
generatePost,
|
generatePost,
|
||||||
|
transformPost,
|
||||||
generateTopics,
|
generateTopics,
|
||||||
generateArticle,
|
generateArticle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user