From cdd507f1c5e3f864a100bbedc7c63e56523a2e43 Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Wed, 24 Jun 2026 20:05:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(postcast-tool):=20AutogenTab=20=E2=80=94?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B8,=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D1=8B,=20=D1=80=D0=BE=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новая вкладка «Автогенерация» в ChannelView: Настройки: - Включить/выключить автогенерацию - posts_per_day: 1-20 (каждый пользователь настраивает сам) - Час и минута запуска генерации Планируется сегодня: - Черновики сегодняшней генерации с кнопкой «Открыть» - Пустое состояние с временем следующей генерации Категории контента: - Список с бейджами «сегодня» / «не сегодня» (ротация) - Форма создания: иконка, название, slug, описание, цвет - Каждая категория раскрывается с панелью тем: · Список тем с жанровыми бейджами [ТУТОРИАЛ][СРАВНЕНИЕ][МНЕНИЕ][ДАЙДЖЕСТ][КЕЙС] · Toggle is_used (✓ / ○) · Добавить тему вручную (Enter или кнопка) · AI-генерация N тем (5/10/15/20/30/50) · Удалить тему Ротация на 7 дней: - Preview скользящего окна — видно что выйдет в каждый день - Подпись «X из Y категорий» с объяснением алгоритма API proxy: /api/engine/channels/:channelId/[[...path]] — catch-all к engine :3035 --- .../channels/[channelId]/[[...path]]/route.js | 52 ++ components/AutogenTab.js | 602 ++++++++++++++++++ components/ChannelView.js | 7 +- 3 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 app/api/engine/channels/[channelId]/[[...path]]/route.js create mode 100644 components/AutogenTab.js diff --git a/app/api/engine/channels/[channelId]/[[...path]]/route.js b/app/api/engine/channels/[channelId]/[[...path]]/route.js new file mode 100644 index 0000000..28f328b --- /dev/null +++ b/app/api/engine/channels/[channelId]/[[...path]]/route.js @@ -0,0 +1,52 @@ +/** + * Catch-all proxy: /api/engine/channels/:channelId/* → engine :3035/api/channels/:channelId/* + * + * Используется AutogenTab и CategoryTab для: + * - GET/POST/PATCH/DELETE /api/engine/channels/:id/categories + * - GET/POST/PATCH/DELETE /api/engine/channels/:id/categories/:catId/topics + * - GET/POST/PATCH /api/engine/channels/:id/autogen + * - POST /api/engine/channels/:id/autogen/run + * - GET /api/engine/channels/:id/autogen/today + * - GET /api/engine/channels/:id/autogen/rotation + */ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; + +const ENGINE = process.env.ENGINE_URL || 'http://127.0.0.1:3030'; +const SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026'; + +async function proxy(req, { params }) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { channelId, path: pathArr } = await params; + const tail = pathArr?.length ? '/' + pathArr.join('/') : ''; + const qs = req.url.split('?')[1]; + const url = `${ENGINE}/api/channels/${channelId}${tail}${qs ? '?' + qs : ''}`; + + const headers = { + 'Content-Type': 'application/json', + 'x-internal-secret': SECRET, + 'x-user-id': String(user.id), + }; + + let body; + if (req.method !== 'GET' && req.method !== 'HEAD') { + body = await req.text(); + } + + try { + const res = await fetch(url, { method: req.method, headers, body, cache: 'no-store' }); + const data = await res.json().catch(() => ({ error: 'invalid engine response' })); + return NextResponse.json(data, { status: res.status }); + } catch (err) { + console.error('[engine proxy] error:', err.message); + return NextResponse.json({ error: err.message }, { status: 502 }); + } +} + +export const GET = proxy; +export const POST = proxy; +export const PATCH = proxy; +export const PUT = proxy; +export const DELETE = proxy; diff --git a/components/AutogenTab.js b/components/AutogenTab.js new file mode 100644 index 0000000..5f4cd6a --- /dev/null +++ b/components/AutogenTab.js @@ -0,0 +1,602 @@ +'use client'; +import { useState, useEffect, useCallback } from 'react'; +import { + Plus, Sparkles, RefreshCw, Loader2, Trash2, Edit3, Save, X, + ChevronDown, ChevronRight, Play, Settings, Calendar, Clock, + BookOpen, Zap, Tag, RotateCcw, Check, AlertCircle, +} from 'lucide-react'; + +// ─── Константы ──────────────────────────────────────────────────────────────── + +const COLORS = [ + { key: 'indigo', cls: 'bg-indigo-50 dark:bg-indigo-950 border-indigo-200 dark:border-indigo-800 text-indigo-700 dark:text-indigo-300' }, + { key: 'emerald', cls: 'bg-emerald-50 dark:bg-emerald-950 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300' }, + { key: 'amber', cls: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300' }, + { key: 'red', cls: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300' }, + { key: 'purple', cls: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800 text-purple-700 dark:text-purple-300' }, + { key: 'blue', cls: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300' }, + { key: 'cyan', cls: 'bg-cyan-50 dark:bg-cyan-950 border-cyan-200 dark:border-cyan-800 text-cyan-700 dark:text-cyan-300' }, + { key: 'orange', cls: 'bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800 text-orange-700 dark:text-orange-300' }, + { key: 'rose', cls: 'bg-rose-50 dark:bg-rose-950 border-rose-200 dark:border-rose-800 text-rose-700 dark:text-rose-300' }, + { key: 'neutral', cls: 'bg-neutral-50 dark:bg-neutral-900 border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300' }, +]; +const colorCls = (k) => (COLORS.find(c => c.key === k) || COLORS[0]).cls; + +const GENRE_LABELS = { + tutorial: { label: 'Туториал', color: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300' }, + comparison: { label: 'Сравнение', color: 'bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300' }, + opinion: { label: 'Мнение', color: 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300' }, + digest: { label: 'Дайджест', color: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300' }, + case: { label: 'Кейс', color: 'bg-rose-100 text-rose-700 dark:bg-rose-950 dark:text-rose-300' }, + news: { label: 'Новость', color: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950 dark:text-cyan-300' }, +}; + +function detectGenre(topic) { + const m = topic?.match(/^\[([^\]]+)\]/); + if (!m) return null; + const map = { 'ТУТОРИАЛ':'tutorial','СРАВНЕНИЕ':'comparison','МНЕНИЕ':'opinion','ДАЙДЖЕСТ':'digest','КЕЙС':'case','НОВОСТЬ':'news' }; + return map[m[1].toUpperCase()] || null; +} + +function GenreBadge({ genre }) { + if (!genre) return null; + const g = GENRE_LABELS[genre]; + if (!g) return null; + return {g.label}; +} + +// ─── Главный компонент ──────────────────────────────────────────────────────── + +export default function AutogenTab({ channel }) { + const [settings, setSettings] = useState(null); + const [categories, setCategories] = useState([]); + const [todayDrafts, setTodayDrafts] = useState([]); + const [rotation, setRotation] = useState([]); + const [loading, setLoading] = useState(true); + const [toast, setToast] = useState(null); + + // Состояние создания категории + const [showNewCat, setShowNewCat] = useState(false); + const [newCat, setNewCat] = useState({ slug:'', name:'', icon:'📝', color:'indigo', description:'' }); + const [savingCat, setSavingCat] = useState(false); + + // Развёрнутая категория (темы) + const [expandedCatId, setExpandedCatId] = useState(null); + + // Настройки autogen + const [showSettings, setShowSettings] = useState(false); + const [autogenForm, setAutogenForm] = useState({ enabled: false, posts_per_day: 3, run_hour: 10, run_minute: 0 }); + const [savingSettings, setSavingSettings] = useState(false); + + // Запуск генерации + const [running, setRunning] = useState(false); + + const flash = (msg, type='success') => { setToast({ msg, type }); setTimeout(() => setToast(null), 3500); }; + + const load = useCallback(async () => { + setLoading(true); + try { + const [statusRes, catsRes, draftsRes, rotRes] = await Promise.allSettled([ + fetch(`/api/engine/channels/${channel.id}/autogen`), + fetch(`/api/engine/channels/${channel.id}/categories`), + fetch(`/api/engine/channels/${channel.id}/autogen/today`), + fetch(`/api/engine/channels/${channel.id}/autogen/rotation?days=7`), + ]); + if (statusRes.status === 'fulfilled' && statusRes.value.ok) { + const d = await statusRes.value.json(); + setSettings(d); + setAutogenForm({ enabled: !!d.enabled, posts_per_day: d.posts_per_day||3, run_hour: d.run_hour||10, run_minute: d.run_minute||0 }); + } + if (catsRes.status === 'fulfilled' && catsRes.value.ok) { + const d = await catsRes.value.json(); + setCategories(d.items || []); + } + if (draftsRes.status === 'fulfilled' && draftsRes.value.ok) { + const d = await draftsRes.value.json(); + setTodayDrafts(d.drafts || []); + } + if (rotRes.status === 'fulfilled' && rotRes.value.ok) { + const d = await rotRes.value.json(); + setRotation(d.preview || []); + } + } catch (e) { flash('Ошибка загрузки: ' + e.message, 'error'); } + setLoading(false); + }, [channel.id]); + + useEffect(() => { load(); }, [load]); + + // ── Сохранить настройки autogen ────────────────────────────────────────── + async function saveAutogenSettings() { + setSavingSettings(true); + try { + const r = await fetch(`/api/engine/channels/${channel.id}/autogen/enable`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(autogenForm), + }); + if (!r.ok) throw new Error((await r.json()).error || 'fail'); + flash('Настройки сохранены'); + setShowSettings(false); + await load(); + } catch (e) { flash(e.message, 'error'); } + setSavingSettings(false); + } + + // ── Создать категорию ───────────────────────────────────────────────────── + async function createCategory() { + if (!newCat.name.trim() || !newCat.slug.trim()) return; + setSavingCat(true); + try { + const r = await fetch(`/api/engine/channels/${channel.id}/categories`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newCat), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error || 'fail'); + flash(`Категория «${newCat.name}» создана`); + setNewCat({ slug:'', name:'', icon:'📝', color:'indigo', description:'' }); + setShowNewCat(false); + await load(); + } catch (e) { flash(e.message, 'error'); } + setSavingCat(false); + } + + // ── Запустить генерацию вручную ──────────────────────────────────────────── + async function runGeneration(categoryId = null) { + setRunning(true); + try { + const r = await fetch(`/api/engine/channels/${channel.id}/autogen/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(categoryId ? { category_id: categoryId } : {}), + }); + if (!r.ok) throw new Error((await r.json()).error || 'fail'); + flash('Генерация запущена — черновики появятся через ~30 сек'); + setTimeout(() => load(), 35000); + } catch (e) { flash(e.message, 'error'); } + setRunning(false); + } + + if (loading) return ( +
+ +
+ ); + + const todaySet = new Set(settings?.categories?.filter(c => c.today_active).map(c => c.id) || []); + const postsPerDay = settings?.posts_per_day || autogenForm.posts_per_day || 3; + + return ( +
+ {toast && ( +
{toast.msg}
+ )} + + {/* ── Хедер с настройками ── */} +
+
+
+

+ + Автогенерация +

+

+ {settings?.enabled + ? `Включена · ${postsPerDay} поста/день · ${String(settings.run_hour||10).padStart(2,'0')}:${String(settings.run_minute||0).padStart(2,'0')}` + : 'Выключена — настрой и включи'} +

+
+
+ + + +
+
+ + {/* Форма настроек */} + {showSettings && ( +
+
+ + + + +
+
+ + +
+
+ )} +
+ + {/* ── Планируется сегодня ── */} +
+
+

+ + Планируется сегодня +

+ + {todayDrafts.length > 0 ? `${todayDrafts.length} из ${postsPerDay} готово` : `Генерация в ${String(settings?.run_hour||10).padStart(2,'0')}:${String(settings?.run_minute||0).padStart(2,'0')}`} + +
+ {todayDrafts.length === 0 ? ( +
+ + Черновики появятся после генерации +
+ ) : ( +
+ {todayDrafts.map(d => ( +
+ {d.category_icon || '📝'} +
+
+ {d.content?.split('\n')[0]?.slice(0,70) || '...'} +
+
+ {d.category_name && {d.category_name}} + {d.genre && } +
+
+ + Открыть + +
+ ))} +
+ )} +
+ + {/* ── Категории ── */} +
+
+

+ + Категории контента + + ({categories.filter(c => c.is_active).length} активных) + +

+ +
+ + {/* Форма новой категории */} + {showNewCat && ( +
+
+
+ + setNewCat(f => ({ ...f, icon: e.target.value }))} + maxLength={4} className="input text-center text-2xl w-full py-1.5" /> +
+
+ + setNewCat(f => ({ + ...f, + name: e.target.value, + slug: f.slug || e.target.value.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''), + }))} className="input text-sm" placeholder="Туториалы" /> +
+
+ + setNewCat(f => ({ ...f, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g,'') }))} + className="input text-sm font-mono" placeholder="tutorials" /> +
+
+ +
+ {COLORS.map(c => ( + + ))} +
+
+
+ + setNewCat(f => ({ ...f, description: e.target.value }))} + className="input text-sm w-full" placeholder="Пошаговые гайды по AI-инструментам" /> +
+
+
+ + +
+
+ )} + + {/* Список категорий */} +
+ {categories.length === 0 && ( +
+ Категорий нет — добавь первую +
+ )} + {categories.map(cat => ( + setExpandedCatId(expandedCatId === cat.id ? null : cat.id)} + onRunNow={() => runGeneration(cat.id)} + running={running} + flash={flash} + onRefresh={load} + /> + ))} +
+
+ + {/* ── Ротация на 7 дней ── */} + {rotation.length > 0 && ( +
+

+ + Ротация категорий на 7 дней +

+
+ {rotation.map((day, i) => { + const isToday = i === 0; + return ( +
+ + {isToday ? 'Сегодня' : new Date(day.date+'T12:00:00').toLocaleDateString('ru-RU',{day:'numeric',month:'short'})} + +
+ {day.categories.map(c => ( + + {c.icon} {c.name} + + ))} +
+
+ ); + })} +
+

+ Скользящее окно {postsPerDay} из {categories.filter(c=>c.is_active).length} категорий — каждый день другой набор. +

+
+ )} +
+ ); +} + +// ─── CategoryRow ─────────────────────────────────────────────────────────────── + +function CategoryRow({ cat, channelId, todayActive, expanded, onToggle, onRunNow, running, flash, onRefresh }) { + const [topics, setTopics] = useState([]); + const [loadingTopics, setLoadingTopics] = useState(false); + const [newTopic, setNewTopic] = useState(''); + const [addingTopic, setAddingTopic] = useState(false); + const [generating, setGenerating] = useState(false); + const [genCount, setGenCount] = useState(15); + + useEffect(() => { + if (expanded && topics.length === 0) loadTopics(); + }, [expanded]); + + async function loadTopics() { + setLoadingTopics(true); + try { + const r = await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics?free=false`); + const d = await r.json(); + setTopics(d.topics || []); + } catch {} + setLoadingTopics(false); + } + + async function addTopic() { + if (!newTopic.trim()) return; + setAddingTopic(true); + try { + const r = await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic: newTopic.trim() }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error); + setNewTopic(''); + await loadTopics(); + onRefresh(); + flash('Тема добавлена'); + } catch (e) { flash(e.message, 'error'); } + setAddingTopic(false); + } + + async function generateTopics() { + setGenerating(true); + try { + await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count: genCount }), + }); + flash(`AI генерирует ${genCount} тем — появятся через ~15 сек`); + setTimeout(() => { loadTopics(); onRefresh(); }, 18000); + } catch (e) { flash(e.message, 'error'); } + setGenerating(false); + } + + async function deleteTopic(id) { + await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics/${id}`, { method: 'DELETE' }); + setTopics(ts => ts.filter(t => t.id !== id)); + onRefresh(); + } + + async function toggleUsed(t) { + await fetch(`/api/engine/channels/${channelId}/categories/${cat.id}/topics/${t.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_used: !t.is_used }), + }); + await loadTopics(); + onRefresh(); + } + + const freeCount = topics.filter(t => !t.is_used).length; + + return ( +
+ {/* Заголовок строки */} +
+ {cat.icon || '📝'} +
+
+ {cat.name} + + {cat.slug} + + {todayActive + ? сегодня + : не сегодня + } +
+
+ +
+
+
+ {todayActive && ( + + )} + +
+
+ + {/* Раскрытая панель тем */} + {expanded && ( +
+ {/* AI генерация */} +
+ + +
+ + {/* Добавить тему вручную */} +
+ setNewTopic(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addTopic()} + placeholder="Новая тема... ([ТУТОРИАЛ] Как сделать X)" + className="input text-sm flex-1" /> + +
+ + {/* Список тем */} + {loadingTopics ? ( +
+ ) : ( +
+ {topics.length === 0 && ( +
+ Тем нет — добавь или сгенерируй +
+ )} + {topics.map(t => { + const genre = t.genre || detectGenre(t.topic); + const cleanTopic = t.topic.replace(/^\[[^\]]+\]\s*/,''); + return ( +
+ + {genre && } + + {cleanTopic} + + +
+ ); + })} +
+ )} + +
+ {freeCount} свободных · {topics.length - freeCount} использованных +
+
+ )} +
+ ); +} diff --git a/components/ChannelView.js b/components/ChannelView.js index 2e4a7b0..a4a6cab 100644 --- a/components/ChannelView.js +++ b/components/ChannelView.js @@ -15,6 +15,7 @@ import FromUrlModal from './FromUrlModal'; import PollModal from './PollModal'; import HashtagSuggest from './HashtagSuggest'; import InboxTab from './InboxTab'; +import AutogenTab from './AutogenTab'; const GOAL_LABELS = { educational: 'Обучение', news: 'Новости', @@ -364,7 +365,7 @@ export default function ChannelView({ channel }) { {/* Вкладки */}
- {[['generate','Создать пост'],['analytics','Аналитика'],['inbox','Inbox']].map(([id,label]) => ( + {[['generate','Создать пост'],['autogen','Автогенерация'],['analytics','Аналитика'],['inbox','Inbox']].map(([id,label]) => (