forked from admin/zeropost-tool
feat: custom prompt UI + AI-style tab in ChannelEdit
ChannelEdit: - Вкладка «AI-стиль»: textarea для ai_style_prompt, выбор image_quality (standard/hd) - Описание моделей: gpt-5-image-mini vs gpt-5.4-image-2 с ценами в кредитах ChannelView: - Коллапсируемое поле «Доп. инструкции для AI» под темой поста - Индикатор (синяя точка) если промт заполнен - customPrompt передаётся в /api/generate
This commit is contained in:
@@ -63,6 +63,7 @@ const IMAGE_PALETTES = [
|
|||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'content', label: 'Контент', icon: Type },
|
{ id: 'content', label: 'Контент', icon: Type },
|
||||||
{ id: 'images', label: 'Картинки', icon: ImageIcon },
|
{ id: 'images', label: 'Картинки', icon: ImageIcon },
|
||||||
|
{ id: 'ai', label: 'AI-стиль', icon: Sparkles },
|
||||||
{ id: 'connect', label: 'Подключение', icon: Plug },
|
{ id: 'connect', label: 'Подключение', icon: Plug },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -106,6 +107,10 @@ export default function ChannelEdit({ channel }) {
|
|||||||
const [tokenVerifying, setTokenVerifying] = useState(false);
|
const [tokenVerifying, setTokenVerifying] = useState(false);
|
||||||
const [tokenStatus, setTokenStatus] = useState(null); // null | 'ok' | 'error'
|
const [tokenStatus, setTokenStatus] = useState(null); // null | 'ok' | 'error'
|
||||||
|
|
||||||
|
// AI-стиль
|
||||||
|
const [aiStylePrompt, setAiStylePrompt] = useState(channel.ai_style_prompt || '');
|
||||||
|
const [imageQuality, setImageQuality] = useState(channel.image_quality || 'standard');
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -121,6 +126,8 @@ export default function ChannelEdit({ channel }) {
|
|||||||
tg_channel_id: tgChannelId.trim() || null,
|
tg_channel_id: tgChannelId.trim() || null,
|
||||||
tg_username: tgUsername.trim() || null,
|
tg_username: tgUsername.trim() || null,
|
||||||
vk_access_token: vkToken.trim() || null,
|
vk_access_token: vkToken.trim() || null,
|
||||||
|
ai_style_prompt: aiStylePrompt.trim() || null,
|
||||||
|
image_quality: imageQuality,
|
||||||
style: {
|
style: {
|
||||||
tone, formality, humor,
|
tone, formality, humor,
|
||||||
post_length: postLength,
|
post_length: postLength,
|
||||||
@@ -442,6 +449,84 @@ export default function ChannelEdit({ channel }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* TAB: AI-стиль */}
|
||||||
|
{tab === 'ai' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Промт для генерации статей */}
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
|
<h3 className="font-semibold text-sm">Стиль генерации статей</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Дополнительные инструкции для AI при автоматической генерации статей в этом канале.
|
||||||
|
Применяется ко всем статьям канала. При ручной генерации можно переопределить.
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
placeholder={`Например:\n• Пиши в стиле новостной заметки, без воды\n• Аудитория: молочные фермеры Сибири\n• Всегда заканчивай призывом к действию\n• Включай конкретные цифры и факты`}
|
||||||
|
value={aiStylePrompt}
|
||||||
|
onChange={e => setAiStylePrompt(e.target.value)}
|
||||||
|
className="input w-full text-sm resize-none"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{aiStylePrompt.length}/1000 символов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Качество изображений */}
|
||||||
|
<div className="card p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<ImageIcon className="w-4 h-4 text-accent" />
|
||||||
|
<h3 className="font-semibold text-sm">Качество генерации картинок</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
v: 'standard',
|
||||||
|
label: 'Стандарт',
|
||||||
|
model: 'gpt-5-image-mini',
|
||||||
|
desc: 'Быстро, дешевле. Подходит для большинства постов в TG и ВК.',
|
||||||
|
cost: '3 кредита / картинка',
|
||||||
|
badge: 'Рекомендуется',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
v: 'hd',
|
||||||
|
label: 'HD',
|
||||||
|
model: 'gpt-5.4-image-2',
|
||||||
|
desc: 'Лучшее качество, фотореализм, поддержка текста на картинке.',
|
||||||
|
cost: '10 кредитов / картинка',
|
||||||
|
badge: 'Для текста на фото',
|
||||||
|
},
|
||||||
|
].map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.v}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImageQuality(opt.v)}
|
||||||
|
className={`p-4 rounded-xl border text-left transition-all ${
|
||||||
|
imageQuality === opt.v
|
||||||
|
? 'border-accent bg-accent/10'
|
||||||
|
: 'border-border hover:border-accent/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-semibold text-sm">{opt.label}</span>
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${
|
||||||
|
opt.v === 'standard' ? 'bg-green-500/20 text-green-400' : 'bg-blue-500/20 text-blue-400'
|
||||||
|
}`}>{opt.badge}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 mb-2">{opt.model}</div>
|
||||||
|
<div className="text-xs text-gray-300">{opt.desc}</div>
|
||||||
|
<div className={`text-xs mt-2 font-medium ${
|
||||||
|
opt.v === 'standard' ? 'text-green-400' : 'text-blue-400'
|
||||||
|
}`}>{opt.cost}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* TAB: Подключение */}
|
{/* TAB: Подключение */}
|
||||||
{tab === 'connect' && (
|
{tab === 'connect' && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ function stripCaption(text) {
|
|||||||
|
|
||||||
export default function ChannelView({ channel }) {
|
export default function ChannelView({ channel }) {
|
||||||
const [topic, setTopic] = useState('');
|
const [topic, setTopic] = useState('');
|
||||||
|
const [customPrompt, setCustomPrompt] = useState('');
|
||||||
|
const [showCustomPrompt, setShowCustomPrompt] = useState(false);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [post, setPost] = useState(null);
|
const [post, setPost] = useState(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -225,6 +227,7 @@ export default function ChannelView({ channel }) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
type: 'post', channelId: channel.id, topic: useTopic, useCritique: true,
|
type: 'post', channelId: channel.id, topic: useTopic, useCritique: true,
|
||||||
|
customPrompt: customPrompt.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const job = await createRes.json();
|
const job = await createRes.json();
|
||||||
@@ -407,12 +410,42 @@ export default function ChannelView({ channel }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="input min-h-[80px] mb-3"
|
className="input min-h-[80px] mb-2"
|
||||||
value={topic}
|
value={topic}
|
||||||
onChange={e => setTopic(e.target.value)}
|
onChange={e => setTopic(e.target.value)}
|
||||||
placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»"
|
placeholder="Тема поста — конкретный заход, не общая категория. Например: «OpenAI выпустил Memory — что это даёт маркетологу»"
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Дополнительные инструкции */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCustomPrompt(v => !v)}
|
||||||
|
className="text-xs text-gray-500 hover:text-accent flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span>{showCustomPrompt ? '▾' : '▸'}</span>
|
||||||
|
Дополнительные инструкции для AI
|
||||||
|
{customPrompt.trim() && <span className="ml-1 w-1.5 h-1.5 rounded-full bg-accent inline-block" />}
|
||||||
|
</button>
|
||||||
|
{showCustomPrompt && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
className="input w-full text-sm resize-none"
|
||||||
|
placeholder={`Например: «Сделай акцент на кейсах из сельского хозяйства» или «Добавь призыв подписаться в конце»`}
|
||||||
|
value={customPrompt}
|
||||||
|
onChange={e => setCustomPrompt(e.target.value)}
|
||||||
|
disabled={generating}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Перебивает стиль канала для этой генерации.
|
||||||
|
{channel.ai_style_prompt && ' Канальный промт также будет применён.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
ИИ напишет пост в стиле твоего канала с учётом примеров
|
ИИ напишет пост в стиле твоего канала с учётом примеров
|
||||||
|
|||||||
Reference in New Issue
Block a user