forked from admin/zeropost-engine
a370b8f7d8
- Персонаж Зеро: 23 позы (zeroCharacter.js), скрипты генерации - Auto-publish статей в TG: multipart upload, кнопки, режим alternating Zero/cover - Fallback цепочка обложек: aiprimetech gpt-5.5 → Pollinations → local SVG (6 палитр) - Auto-series: Claude haiku определяет серию для каждой статьи автоматически - Channel stats: подписчики, история, delta 24h/7d - Photo-search: Yandex API, профили доменов, Redis лимиты - Scheduled posts runner: backfill, preview, queue, cancel - promptBuilder: author_persona Зеро, голос от первого лица - Fixes: dollar-placeholder bugs в PATCH channels/autogen, listArticles фильтры - AI model: gpt-5.5 для image generation
148 lines
6.0 KiB
JavaScript
148 lines
6.0 KiB
JavaScript
// Тест: генерируем 3 ракурса Зеро. Каждая картинка — до 5 попыток (GPT-5.2 капризничает с tool_choice).
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const axios = require('axios');
|
||
const FormData = require('form-data');
|
||
|
||
const ROOT = '/var/www/zeropost-engine';
|
||
process.chdir(ROOT);
|
||
require('dotenv').config({ path: path.join(ROOT, '.env') });
|
||
const config = require(path.join(ROOT, 'src/config'));
|
||
const settings = require(path.join(ROOT, 'src/services/settings'));
|
||
const { query } = require(path.join(ROOT, 'src/config/db'));
|
||
|
||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
||
let sharp = null;
|
||
try { sharp = require('sharp'); } catch {}
|
||
|
||
// Базовое описание персонажа — повторяется в каждом промпте чтобы сохранять консистентность.
|
||
const CHARACTER_BASE = `
|
||
Character: a small, friendly mascot named Zero.
|
||
Body: a soft rounded square shape (like a plump pixel or a chubby tetris block), about the size of a small companion creature.
|
||
Color: bright emerald green (#10b981) with a slight gradient to teal, soft and matte.
|
||
Face: two simple round black dot eyes, a small understated mouth that expresses the current emotion.
|
||
Personality: friendly, curious, enthusiastic, a bit nerdy.
|
||
Style: clean modern vector illustration, flat design with soft shading, no outlines.
|
||
Background: warm off-white (#fafaf9) with subtle geometric shapes in light teal/emerald, like Notion/Linear/Anthropic editorial illustrations.
|
||
Composition: 1:1 square format with comfortable padding, character is the main focus.
|
||
Strictly: no text, no letters, no logos, no humans, no realistic robots, no glowing nodes, no circuits.
|
||
`.trim();
|
||
|
||
const POSES = [
|
||
{
|
||
name: 'avatar',
|
||
desc: `Front-facing portrait pose. Zero is looking directly at the viewer with curious wide-open eyes and a small confident smile. Sits centered in the frame. Used as channel avatar.`,
|
||
},
|
||
{
|
||
name: 'laptop',
|
||
desc: `Zero sits at a tiny laptop (the laptop is also stylized geometric, in cream/emerald colors). Eyes focused on the screen, small thoughtful expression. Cozy setup with a small plant nearby. The mascot is enthusiastic about coding.`,
|
||
},
|
||
{
|
||
name: 'eureka',
|
||
desc: `Zero has just discovered something exciting. One small stick arm raised in the air with a soft glowing dot above the head representing an idea. Eyes wide and sparkly, big enthusiastic smile. Confetti or stylized geometric sparkles around.`,
|
||
},
|
||
];
|
||
|
||
async function generateOne(prompt, attempt = 1) {
|
||
const MAX_ATTEMPTS = 5;
|
||
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
|
||
const wrapped = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
|
||
try {
|
||
const res = await axios.post(
|
||
`${config.ai.baseUrl}/responses`,
|
||
{
|
||
model, input: wrapped,
|
||
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 || !imgCall.result) {
|
||
if (attempt < MAX_ATTEMPTS) {
|
||
console.log(`[zero] retry attempt=${attempt + 1} (no image_generation_call)`);
|
||
return generateOne(prompt, attempt + 1);
|
||
}
|
||
throw new Error(`No image after ${MAX_ATTEMPTS} attempts`);
|
||
}
|
||
return Buffer.from(imgCall.result, 'base64');
|
||
} catch (err) {
|
||
if (attempt < MAX_ATTEMPTS) {
|
||
const msg = err.response?.data?.error?.message || err.message;
|
||
console.log(`[zero] retry attempt=${attempt + 1} (${msg.slice(0, 60)})`);
|
||
return generateOne(prompt, attempt + 1);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function processBytes(bytes, name) {
|
||
const ts = Date.now();
|
||
const webpName = `zero-${name}-${ts}.webp`;
|
||
const webpPath = path.join(UPLOADS_DIR, webpName);
|
||
if (sharp) {
|
||
await sharp(bytes)
|
||
.resize(1024, 1024, { fit: 'cover' })
|
||
.webp({ quality: 88 })
|
||
.toFile(webpPath);
|
||
} else {
|
||
fs.writeFileSync(webpPath, bytes);
|
||
}
|
||
return webpPath;
|
||
}
|
||
|
||
(async () => {
|
||
try {
|
||
console.log('[zero] generating 3 poses sequentially (with retries)...');
|
||
const results = [];
|
||
for (const p of POSES) {
|
||
console.log(`[zero] starting ${p.name}...`);
|
||
const prompt = `${CHARACTER_BASE}\n\nPose: ${p.desc}`;
|
||
const bytes = await generateOne(prompt);
|
||
const localPath = await processBytes(bytes, p.name);
|
||
console.log(`[zero] ${p.name} → ${localPath}`);
|
||
results.push({ name: p.name, path: localPath });
|
||
}
|
||
|
||
// Шлём все 3 в TG как media group с подписью
|
||
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
|
||
const channel = chs[0];
|
||
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
|
||
|
||
const form = new FormData();
|
||
form.append('chat_id', String(channel.tg_channel_id));
|
||
|
||
const media = results.map((r, i) => ({
|
||
type: 'photo',
|
||
media: `attach://photo${i}`,
|
||
caption: i === 0
|
||
? `🧪 Тест Зеро — три позы:\n1. аватар 2. за ноутом 3. эврика`
|
||
: undefined,
|
||
}));
|
||
form.append('media', JSON.stringify(media));
|
||
results.forEach((r, i) => {
|
||
form.append(`photo${i}`, fs.createReadStream(r.path));
|
||
});
|
||
|
||
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendMediaGroup`, form, {
|
||
headers: form.getHeaders(),
|
||
timeout: 60000,
|
||
maxContentLength: Infinity,
|
||
maxBodyLength: Infinity,
|
||
});
|
||
|
||
const msgIds = (res.data?.result || []).map(m => m.message_id);
|
||
console.log(`[zero] sent media group, message_ids=${msgIds.join(',')}`);
|
||
console.log('[zero] DONE');
|
||
process.exit(0);
|
||
} catch (err) {
|
||
console.error('[zero] FAILED:', err.response?.data || err.message);
|
||
process.exit(1);
|
||
}
|
||
})();
|