feat: AI Press Secretary - content generation and publishing platform
Features: - AI-powered post generation from brand profiles - Image generation with templates - Content planning - Telegram publishing - Social profile analyzer - Community organization support - Markdown to HTML conversion for Telegram - Inline AI chat for post editing Deploy: - Add deployment scripts for Linux servers - Add PowerShell deploy script for Windows - Add Nginx and SSL setup scripts - Add backup and restore scripts
This commit is contained in:
1 parent
81625b2e6f
commit
c6048aeea0
37 files changed
+7036
-224
No files matched your search
+54
-4
@@ -1,7 +1,57 @@
|
||||
.env
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
users.db
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
cabinet_service/media/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# База данных
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Медиа файлы
|
||||
media/
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
*.gif
|
||||
*.webp
|
||||
|
||||
# Логи
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Переменные окружения
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Секреты
|
||||
secrets/
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# OpenWork
|
||||
.opencode/
|
||||
|
||||
# Временные файлы
|
||||
*.tmp
|
||||
*.bak
|
||||
*.orig
|
||||
@@ -0,0 +1,103 @@
|
||||
# ИИ Пресс-секретарь
|
||||
|
||||
AI-powered content generation and publishing platform for entrepreneurs and community organizations.
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Локальный запуск (Windows)
|
||||
|
||||
```powershell
|
||||
# 1. Клонировать репозиторий
|
||||
git clone https://github.com/username/pressa.git
|
||||
cd pressa
|
||||
|
||||
# 2. Создать виртуальное окружение
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
|
||||
# 3. Установить зависимости
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. Создать .env файл
|
||||
# Скопируй config.py в .env и заполни переменные
|
||||
|
||||
# 5. Запустить сервер
|
||||
python -m uvicorn cabinet_service.app:app --host 127.0.0.1 --port 8001
|
||||
```
|
||||
|
||||
Открой в браузере: http://127.0.0.1:8001
|
||||
|
||||
### Деплой на сервер (Linux)
|
||||
|
||||
Смотри папку `deploy/`:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
chmod +x *.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Или пошагово:
|
||||
1. `./deploy.sh` — установка приложения
|
||||
2. `./nginx-setup.sh` — настройка веб-сервера
|
||||
3. `./ssl-setup.sh` — SSL сертификат
|
||||
|
||||
## 📋 Требования
|
||||
|
||||
- Python 3.12+
|
||||
- SQLite (встроен в Python)
|
||||
- Nginx (для production)
|
||||
- Доступ к AI API (PiAPI, OpenAI, OpenRouter)
|
||||
|
||||
## 🔧 Переменные окружения
|
||||
|
||||
Создай файл `.env` в корне проекта:
|
||||
|
||||
```env
|
||||
BOT_TOKEN=your_telegram_bot_token
|
||||
PIAPI_API_KEY=your_piapi_key
|
||||
PIAPI_MODEL=gpt-4o
|
||||
PROXY_URL=http://user:pass@host:port
|
||||
CABINET_URL=https://your-domain.com
|
||||
```
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
pressa/
|
||||
├── cabinet_service/ # Веб-приложение (FastAPI)
|
||||
│ ├── app.py # Основной файл
|
||||
│ ├── templates/ # HTML шаблоны
|
||||
│ ├── static/ # CSS/JS
|
||||
│ └── media/ # Загруженные файлы
|
||||
├── ai_core/ # AI модули
|
||||
│ ├── neural_network.py # Генерация текста
|
||||
│ ├── image_generator.py # Генерация изображений
|
||||
│ ├── social_analyzer.py # Анализ соцсетей
|
||||
│ └── telegram_publisher.py # Публикация в Telegram
|
||||
├── deploy/ # Скрипты деплоя
|
||||
├── config.py # Конфигурация
|
||||
├── requirements.txt # Зависимости
|
||||
└── README.md # Этот файл
|
||||
```
|
||||
|
||||
## 🎯 Возможности
|
||||
|
||||
- ✍️ Генерация постов с помощью AI
|
||||
- 🎨 Генерация изображений
|
||||
- 📅 Контент-план
|
||||
- 📊 Медиа-образ (бренд-бук)
|
||||
- 📤 Публикация в Telegram
|
||||
- 🤖 Чат с AI для редактирования постов
|
||||
- 📱 Адаптивный дизайн
|
||||
|
||||
## 🛠️ Технологии
|
||||
|
||||
- **Backend:** Python, FastAPI, SQLite
|
||||
- **Frontend:** HTML, CSS, JavaScript (vanilla)
|
||||
- **AI:** OpenAI GPT-4, PiAPI, OpenRouter
|
||||
- **Deploy:** Gunicorn, Nginx, systemd
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,4 @@
|
||||
from cabinet_service.runner import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
+281
-14
@@ -7,13 +7,32 @@ from config import PIAPI_API_KEY, PIAPI_IMAGE_MODEL, get_proxies
|
||||
|
||||
PIAPI_IMAGE_URL = "https://api.piapi.ai/v1/images/generations"
|
||||
|
||||
# Доступные размеры (gpt-image-1)
|
||||
IMAGE_SIZES = {
|
||||
"square": ("1024x1024", "Квадрат 1024x1024"),
|
||||
"wide": ("1536x1024", "Горизонтальный 1536x1024"),
|
||||
"tall": ("1024x1536", "Вертикальный 1024x1536"),
|
||||
# Размеры по моделям
|
||||
# gpt-image-1: 1024x1024, 1536x1024, 1024x1536
|
||||
# dall-e-3: 1024x1024, 1792x1024, 1024x1792
|
||||
_MODEL_SIZES = {
|
||||
"gpt-image-1": {
|
||||
"square": ("1024x1024", "Квадрат 1024×1024"),
|
||||
"wide": ("1536x1024", "Горизонтальный 1536×1024"),
|
||||
"tall": ("1024x1536", "Вертикальный 1024×1536"),
|
||||
},
|
||||
"dall-e-3": {
|
||||
"square": ("1024x1024", "Квадрат 1024×1024"),
|
||||
"wide": ("1792x1024", "Горизонтальный 1792×1024"),
|
||||
"tall": ("1024x1792", "Вертикальный 1024×1792"),
|
||||
},
|
||||
}
|
||||
|
||||
# Размеры по умолчанию (для неизвестных моделей — как у gpt-image-1)
|
||||
DEFAULT_SIZES = {
|
||||
"square": ("1024x1024", "Квадрат 1024×1024"),
|
||||
"wide": ("1536x1024", "Горизонтальный 1536×1024"),
|
||||
"tall": ("1024x1536", "Вертикальный 1024×1536"),
|
||||
}
|
||||
|
||||
# Обратная совместимость: IMAGE_SIZES для шаблонов
|
||||
IMAGE_SIZES = DEFAULT_SIZES
|
||||
|
||||
# Пресеты стилей - дописываются к промпту
|
||||
STYLE_PRESETS = {
|
||||
"none": ("Без стиля", ""),
|
||||
@@ -24,6 +43,132 @@ STYLE_PRESETS = {
|
||||
"flat": ("Плоская графика", ", flat vector illustration, bold shapes, social media style"),
|
||||
}
|
||||
|
||||
# Шаблоны изображений — готовые пресеты для быстрой генерации
|
||||
# prompt — для свободной генерации (подставляется в textarea)
|
||||
# prompt_template — для генерации к посту ({post_text} заменяется на текст поста)
|
||||
IMAGE_TEMPLATES = [
|
||||
{
|
||||
"key": "product",
|
||||
"name": "Товар",
|
||||
"icon": "🛍️",
|
||||
"prompt": "Professional product photo on a clean studio background, soft gradient lighting, high-end commercial photography",
|
||||
"prompt_template": "Professional product photo on a clean studio background, soft gradient lighting, high-end commercial photography. Product related to: {post_text}",
|
||||
"style": "photo",
|
||||
"size": "square",
|
||||
"hint": "Фото товара на чистом фоне — для карточек и каталогов",
|
||||
},
|
||||
{
|
||||
"key": "lifestyle",
|
||||
"name": "Лайфстайл",
|
||||
"icon": "📸",
|
||||
"prompt": "Natural lifestyle scene, warm tones, candid feel, natural light, everyday setting",
|
||||
"prompt_template": "Natural lifestyle scene related to: {post_text}. A person in a cozy everyday setting, warm tones, candid feel, natural light",
|
||||
"style": "photo",
|
||||
"size": "wide",
|
||||
"hint": "Живая сцена из жизни — для постов и сторис",
|
||||
},
|
||||
{
|
||||
"key": "food",
|
||||
"name": "Еда",
|
||||
"icon": "🍕",
|
||||
"prompt": "Beautiful food photography, overhead flat lay or 45-degree angle, garnished plate, rustic wooden table, soft natural light, appetizing colors",
|
||||
"prompt_template": "Beautiful food photography of {post_text}, overhead flat lay or 45-degree angle, garnished plate, rustic wooden table, soft natural light, appetizing colors",
|
||||
"style": "photo",
|
||||
"size": "square",
|
||||
"hint": "Вкусная еда — для кафе, ресторанов, рецептов",
|
||||
},
|
||||
{
|
||||
"key": "business",
|
||||
"name": "Бизнес",
|
||||
"icon": "🏢",
|
||||
"prompt": "Professional business setting, modern office or meeting room, people in smart casual clothing, clean and bright atmosphere",
|
||||
"prompt_template": "Professional business setting illustrating: {post_text}. Modern office, people in smart casual, clean bright atmosphere",
|
||||
"style": "photo",
|
||||
"size": "wide",
|
||||
"hint": "Деловая атмосфера — для LinkedIn и презентаций",
|
||||
},
|
||||
{
|
||||
"key": "abstract",
|
||||
"name": "Абстрактный фон",
|
||||
"icon": "🎨",
|
||||
"prompt": "Abstract gradient background with flowing organic shapes, vibrant modern color palette, smooth transitions, depth effect",
|
||||
"prompt_template": "Abstract gradient background with flowing organic shapes and stylized text overlay reading: {post_text}. Vibrant modern color palette, smooth transitions, depth effect",
|
||||
"style": "none",
|
||||
"size": "square",
|
||||
"hint": "Красивый фон — для цитат, анонсов, обложек",
|
||||
},
|
||||
{
|
||||
"key": "portrait",
|
||||
"name": "Портрет",
|
||||
"icon": "👤",
|
||||
"prompt": "Professional portrait photo, soft studio lighting, blurred background, sharp focus on face, natural skin tones, confident expression",
|
||||
"prompt_template": "Professional portrait photo of a person who {post_text}. Soft studio lighting, blurred background, sharp focus, natural skin tones, confident expression",
|
||||
"style": "photo",
|
||||
"size": "square",
|
||||
"hint": "Портрет или аватар — для профилей и «О нас»",
|
||||
},
|
||||
{
|
||||
"key": "infographic",
|
||||
"name": "Инфографика",
|
||||
"icon": "📊",
|
||||
"prompt": "Clean infographic layout with icons, charts, and data visualization on white background, modern flat design, bold readable typography",
|
||||
"prompt_template": "Clean infographic design with the following text and data clearly rendered on it: {post_text}. White background, modern flat design, icons and charts, bold readable typography — the text from the post MUST be visible and legible on the image",
|
||||
"style": "flat",
|
||||
"size": "tall",
|
||||
"hint": "Визуализация данных — для статистик и гайдов",
|
||||
},
|
||||
{
|
||||
"key": "banner",
|
||||
"name": "Рекламный баннер",
|
||||
"icon": "🎯",
|
||||
"prompt": "Eye-catching advertising banner with bold typography, vibrant colors, modern promotional design, call to action",
|
||||
"prompt_template": "Eye-catching advertising banner with the following text prominently displayed: {post_text}. Vibrant colors, modern promotional design, the text must be the main focus of the image, bold readable typography",
|
||||
"style": "none",
|
||||
"size": "wide",
|
||||
"hint": "Баннер для рекламы — с текстом поста",
|
||||
},
|
||||
{
|
||||
"key": "nature",
|
||||
"name": "Природа",
|
||||
"icon": "🌄",
|
||||
"prompt": "Breathtaking nature landscape, golden hour lighting, vivid colors, dramatic sky, professional landscape photography",
|
||||
"prompt_template": "Breathtaking nature landscape illustrating the theme: {post_text}. Golden hour lighting, vivid colors, dramatic sky, professional landscape photography",
|
||||
"style": "photo",
|
||||
"size": "wide",
|
||||
"hint": "Пейзажи и природа — для мотивации и фона",
|
||||
},
|
||||
{
|
||||
"key": "minimal",
|
||||
"name": "Минимализм",
|
||||
"icon": "✨",
|
||||
"prompt": "Minimalist composition, single focal point, lots of white space, clean geometric shapes, soft muted colors, elegant simplicity",
|
||||
"prompt_template": "Minimalist design with the following text as the centerpiece: {post_text}. Clean white background, elegant typography, lots of breathing space, soft muted accent color",
|
||||
"style": "minimal",
|
||||
"size": "square",
|
||||
"hint": "Чистый минимализм — для премиальных брендов",
|
||||
},
|
||||
{
|
||||
"key": "story",
|
||||
"name": "Сторис",
|
||||
"icon": "📱",
|
||||
"prompt": "Vertical social media story, gradient background, centered composition, modern aesthetic, bold colors",
|
||||
"prompt_template": "Vertical social media story with the following text as the main element: {post_text}. Gradient background, centered composition, modern aesthetic, bold colors, the text must be clearly readable",
|
||||
"style": "none",
|
||||
"size": "tall",
|
||||
"hint": "Вертикальный формат — для Instagram/TikTok сторис",
|
||||
},
|
||||
{
|
||||
"key": "before_after",
|
||||
"name": "До / После",
|
||||
"icon": "🔄",
|
||||
"prompt": "Split composition before/after comparison, left side 'before', right side 'after', clean dividing line, matching lighting",
|
||||
"prompt_template": "Split composition before/after comparison related to: {post_text}. Left side shows 'before' state, right side shows 'after' transformation, clean dividing line, matching lighting",
|
||||
"style": "photo",
|
||||
"size": "wide",
|
||||
"hint": "Сравнение до/после — для кейсов и результатов",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def detect_ext(data: bytes) -> str:
|
||||
"""Определяет расширение файла по magic-байтам."""
|
||||
@@ -38,24 +183,56 @@ def detect_ext(data: bytes) -> str:
|
||||
return ".png"
|
||||
|
||||
|
||||
class ImageGenerator:
|
||||
"""Генерация изображений через PiAPI (OpenAI-совместимый /images/generations)."""
|
||||
def get_sizes_for_model(model: str) -> dict:
|
||||
"""Возвращает доступные размеры для конкретной модели."""
|
||||
if not model:
|
||||
return DEFAULT_SIZES
|
||||
# Точное совпадение
|
||||
if model in _MODEL_SIZES:
|
||||
return _MODEL_SIZES[model]
|
||||
# Частичное совпадение (напр. openai/dall-e-3)
|
||||
for key, sizes in _MODEL_SIZES.items():
|
||||
if key in model:
|
||||
return sizes
|
||||
return DEFAULT_SIZES
|
||||
|
||||
def __init__(self, model: str | None = None):
|
||||
self.model = model or PIAPI_IMAGE_MODEL
|
||||
self.proxies = get_proxies()
|
||||
|
||||
class ImageGenerator:
|
||||
"""Генерация изображений через OpenAI-совместимый /images/generations."""
|
||||
|
||||
def __init__(self, model=None, provider_settings=None):
|
||||
"""provider_settings — опциональный dict:
|
||||
api_key, base_url, image_model
|
||||
Если не передан — используются глобальные настройки PiAPI."""
|
||||
if provider_settings and provider_settings.get("api_key"):
|
||||
self.api_key = provider_settings["api_key"]
|
||||
base = provider_settings.get("base_url") or "https://api.piapi.ai/v1"
|
||||
self.image_url = base.rstrip("/") + "/images/generations"
|
||||
self.model = model or provider_settings.get("image_model") or PIAPI_IMAGE_MODEL
|
||||
self.provider_type = provider_settings.get("provider_type", "custom")
|
||||
# Прокси для всех провайдеров (доступ из России к зарубежным API)
|
||||
self.proxies = get_proxies()
|
||||
else:
|
||||
self.api_key = PIAPI_API_KEY
|
||||
self.image_url = PIAPI_IMAGE_URL
|
||||
self.model = model or PIAPI_IMAGE_MODEL
|
||||
self.provider_type = "piapi"
|
||||
self.proxies = get_proxies()
|
||||
|
||||
def generate(self, prompt: str, size: str = "square") -> bytes:
|
||||
"""Генерирует картинку и возвращает её байты. Поднимает исключение при ошибке."""
|
||||
if size not in IMAGE_SIZES:
|
||||
sizes = get_sizes_for_model(self.model)
|
||||
if size not in sizes:
|
||||
size = "square"
|
||||
size_value = IMAGE_SIZES[size][0]
|
||||
size_value = sizes[size][0]
|
||||
|
||||
# DALL-E 3 больше не поддерживается OpenAI — используем gpt-image-1
|
||||
# response_format и quality не передаём (gpt-image-1 возвращает b64_json по умолчанию)
|
||||
response = requests.post(
|
||||
PIAPI_IMAGE_URL,
|
||||
self.image_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {PIAPI_API_KEY}",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
@@ -87,3 +264,93 @@ class ImageGenerator:
|
||||
if style in STYLE_PRESETS:
|
||||
return prompt.strip() + STYLE_PRESETS[style][1]
|
||||
return prompt.strip()
|
||||
|
||||
def generate_with_reference(self, prompt: str, reference_image_bytes: bytes, size: str = "square", chat_settings=None) -> bytes:
|
||||
"""Генерирует изображение на основе промпта + исходного изображения.
|
||||
Пробует: 1) text-провайдер chat/completions, 2) image-провайдер chat/completions, 3) fallback без картинки.
|
||||
"""
|
||||
sizes = get_sizes_for_model(self.model)
|
||||
if size not in sizes:
|
||||
size = "square"
|
||||
size_value = sizes[size][0]
|
||||
|
||||
# Кодируем исходное изображение в base64
|
||||
img_b64 = base64.b64encode(reference_image_bytes).decode("utf-8")
|
||||
mime = "image/png"
|
||||
if reference_image_bytes[:2] == b"\xff\xd8":
|
||||
mime = "image/jpeg"
|
||||
elif reference_image_bytes[:4] == b"RIFF":
|
||||
mime = "image/webp"
|
||||
|
||||
# Список провайдеров для попытки chat/completions с image input
|
||||
candidates = []
|
||||
if chat_settings and chat_settings.get("api_key"):
|
||||
candidates.append((chat_settings["api_key"], chat_settings.get("base_url", "https://api.openai.com/v1"), "text"))
|
||||
# image-провайдер тоже может уметь chat/completions (PiAPI/OpenAI)
|
||||
if self.api_key and (not chat_settings or self.api_key != chat_settings.get("api_key")):
|
||||
candidates.append((self.api_key, self.image_url.rsplit("/images/generations", 1)[0], "image"))
|
||||
# Fallback: default OpenAI
|
||||
if not candidates:
|
||||
candidates.append((self.api_key, "https://api.openai.com/v1", "default"))
|
||||
|
||||
last_error = None
|
||||
for api_key, base_url, label in candidates:
|
||||
chat_url = base_url.rstrip("/") + "/chat/completions"
|
||||
print(f"[ImageGenerator] reference image -> {label} provider: {chat_url}")
|
||||
try:
|
||||
response = requests.post(
|
||||
chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{mime};base64,{img_b64}"}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
proxies=self.proxies,
|
||||
timeout=240,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
last_error = f"{response.status_code} {response.reason}"
|
||||
print(f"[ImageGenerator] {label} провайдер вернул {last_error}, пробуем следующий")
|
||||
continue
|
||||
|
||||
resp_json = response.json()
|
||||
msg = resp_json.get("choices", [{}])[0].get("message", {})
|
||||
content = msg.get("content", "")
|
||||
|
||||
# Парсим картинку из ответа
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||
url = part.get("image_url", {}).get("url", "")
|
||||
if url.startswith("data:"):
|
||||
return base64.b64decode(url.split(",", 1)[1])
|
||||
if isinstance(content, str) and len(content) > 1000:
|
||||
try:
|
||||
return base64.b64decode(content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"[ImageGenerator] {label} провайдер не вернул картинку")
|
||||
last_error = "no image in response"
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
print(f"[ImageGenerator] {label} провайдер ошибка: {last_error}")
|
||||
|
||||
# Все провайдеры не сработали — fallback без картинки
|
||||
print(f"[ImageGenerator] ни один провайдер не поддержал image input, генерируем без референса")
|
||||
return self.generate(prompt, size)
|
||||
+599
-16
@@ -6,20 +6,45 @@ from config import PIAPI_API_KEY, PIAPI_MODEL, get_proxies
|
||||
|
||||
PIAPI_URL = "https://api.piapi.ai/v1/chat/completions"
|
||||
|
||||
|
||||
class ContentGenerator:
|
||||
def __init__(self):
|
||||
"""Инициализация генератора контента"""
|
||||
def __init__(self, provider_settings=None):
|
||||
"""Инициализация генератора контента.
|
||||
provider_settings — опциональный dict с ключами:
|
||||
provider_type, api_key, base_url, text_model
|
||||
Если не передан — используются глобальные настройки PiAPI из config."""
|
||||
print("🔄 Инициализация ContentGenerator...")
|
||||
|
||||
# Проверяем, есть ли ключ PiAPI
|
||||
if PIAPI_API_KEY:
|
||||
if provider_settings and provider_settings.get("api_key"):
|
||||
# Per-user настройки
|
||||
self.api_key = provider_settings["api_key"]
|
||||
self.base_url = provider_settings.get("base_url") or PIAPI_URL.replace("/chat/completions", "")
|
||||
self.model = provider_settings.get("text_model") or PIAPI_MODEL
|
||||
self.use_piapi = True
|
||||
self.chat_url = self.base_url.rstrip("/") + "/chat/completions"
|
||||
self.provider_type = provider_settings.get("provider_type", "custom")
|
||||
# Прокси используется для всех провайдеров (если настроен в .env)
|
||||
# Это нужно для доступа из России к зарубежным API
|
||||
self.proxies = get_proxies()
|
||||
print(f"✅ Провайдер: {self.provider_type} | модель: {self.model} | прокси: {'да' if self.proxies else 'нет'}")
|
||||
elif PIAPI_API_KEY:
|
||||
# Глобальные настройки из config
|
||||
self.api_key = PIAPI_API_KEY
|
||||
self.base_url = PIAPI_URL.replace("/chat/completions", "")
|
||||
self.model = PIAPI_MODEL
|
||||
self.use_piapi = True
|
||||
print("✅ PiAPI подключен!")
|
||||
self.chat_url = PIAPI_URL
|
||||
self.provider_type = "piapi"
|
||||
self.proxies = get_proxies()
|
||||
print("✅ PiAPI подключен (глобальные настройки)!")
|
||||
else:
|
||||
print("⚠️ PiAPI ключ не найден, использую шаблоны")
|
||||
print("⚠️ API ключ не найден, использую шаблоны")
|
||||
self.use_piapi = False
|
||||
|
||||
self.api_key = None
|
||||
self.chat_url = PIAPI_URL
|
||||
self.model = PIAPI_MODEL
|
||||
self.provider_type = "piapi"
|
||||
self.proxies = get_proxies()
|
||||
# Шаблоны для запасного варианта (если PiAPI не работает)
|
||||
self.templates = {
|
||||
'instagram': [
|
||||
@@ -159,10 +184,10 @@ class ContentGenerator:
|
||||
"""
|
||||
|
||||
response = requests.post(
|
||||
PIAPI_URL,
|
||||
self.chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {PIAPI_API_KEY}"
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
@@ -173,7 +198,7 @@ class ContentGenerator:
|
||||
"temperature": 0.9,
|
||||
"max_tokens": 500
|
||||
},
|
||||
proxies=get_proxies(),
|
||||
proxies=self.proxies,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -215,10 +240,10 @@ class ContentGenerator:
|
||||
"""
|
||||
|
||||
response = requests.post(
|
||||
PIAPI_URL,
|
||||
self.chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {PIAPI_API_KEY}"
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
@@ -229,7 +254,7 @@ class ContentGenerator:
|
||||
"temperature": 0.9,
|
||||
"max_tokens": 500
|
||||
},
|
||||
proxies=get_proxies(),
|
||||
proxies=self.proxies,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -244,6 +269,470 @@ class ContentGenerator:
|
||||
post = self._generate_with_templates(business_type, business_description, platform)
|
||||
return f"{post}\n\n💭 Пожелания учтены частично (без ИИ): {wishes}"
|
||||
|
||||
def generate_post_from_brand_profile(self, brand_data, platform, style, wishes):
|
||||
"""Генерирует пост на основе медиа-образа (данных опроса brand_survey).
|
||||
brand_data — dict с полями опроса."""
|
||||
print(f"🔄 Генерация поста из медиа-образа для {platform}...")
|
||||
|
||||
brand_type = brand_data.get("brand_type", "personal")
|
||||
is_org = (brand_type == "organization")
|
||||
is_community = (brand_type == "community")
|
||||
|
||||
# Собираем ключевые поля медиа-образа
|
||||
client_name = brand_data.get("client_name", "")
|
||||
client_role = brand_data.get("client_role", "")
|
||||
brand_essence = brand_data.get("brand_essence", "")
|
||||
positioning_who = brand_data.get("positioning_who", "")
|
||||
positioning_problem = brand_data.get("positioning_problem", "")
|
||||
positioning_result = brand_data.get("positioning_result", "")
|
||||
usp = brand_data.get("usp", "")
|
||||
tov_description = brand_data.get("tov_description", "")
|
||||
tov_references = brand_data.get("tov_references", "")
|
||||
tov_axes = brand_data.get("tov_axes", "")
|
||||
prompt_context = brand_data.get("prompt_context", "")
|
||||
prompt_tov = brand_data.get("prompt_tov", "")
|
||||
prompt_structure = brand_data.get("prompt_structure", "")
|
||||
prompt_directions = brand_data.get("prompt_directions", "")
|
||||
prompt_formats = brand_data.get("prompt_formats", "")
|
||||
prompt_technical = brand_data.get("prompt_technical", "")
|
||||
|
||||
# Поля организации
|
||||
org_name = brand_data.get("org_name", "")
|
||||
org_industry = brand_data.get("org_industry", "")
|
||||
org_mission = brand_data.get("org_mission", "")
|
||||
org_products = brand_data.get("org_products", "")
|
||||
org_team_size = brand_data.get("org_team_size", "")
|
||||
positioning_difference = brand_data.get("positioning_difference", "")
|
||||
community_formats = brand_data.get("community_formats", "")
|
||||
|
||||
platform_names = {
|
||||
'instagram': 'Instagram',
|
||||
'linkedin': 'LinkedIn',
|
||||
'telegram': 'Telegram',
|
||||
'twitter': 'Twitter/X',
|
||||
'ad': 'рекламный пост'
|
||||
}
|
||||
|
||||
if is_community:
|
||||
identity_name = org_name
|
||||
identity_role = org_industry
|
||||
identity_block = f"""=== ОБЩЕСТВЕННАЯ ОРГАНИЗАЦИЯ ===
|
||||
Название: {org_name}
|
||||
Сфера: {org_industry}
|
||||
Миссия: {org_mission}
|
||||
Направления деятельности: {org_products}
|
||||
Участников: {org_team_size}
|
||||
Формат взаимодействия: {community_formats}
|
||||
Сущность бренда: {brand_essence}
|
||||
УТП: {usp}"""
|
||||
positioning_block = f"""=== ПОЗИЦИОНИРОВАНИЕ ===
|
||||
Для кого создано: {positioning_who}
|
||||
Какую проблему решает: {positioning_problem}
|
||||
Ценность для участника: {positioning_result}
|
||||
Чем отличаемся от других: {positioning_difference}"""
|
||||
tone_intro = "Ты — профессиональный копирайтер, создающий контент для общественной организации / бизнес-сообщества."
|
||||
post_intro = f"Напиши пост для {platform_names.get(platform, 'соцсетей')} от имени организации «{org_name}»."
|
||||
system_msg = f"Ты профессиональный копирайтер. Пишешь контент для бизнес-сообщества «{org_name}» в соответствии с его миссией, ценностями и Tone of Voice."
|
||||
elif is_org:
|
||||
identity_name = org_name
|
||||
identity_role = org_industry
|
||||
identity_block = f"""=== ОРГАНИЗАЦИЯ ===
|
||||
Название: {org_name}
|
||||
Сфера: {org_industry}
|
||||
Миссия: {org_mission}
|
||||
Продукты/услуги: {org_products}
|
||||
Сотрудников: {org_team_size}
|
||||
Сущность бренда: {brand_essence}
|
||||
УТП: {usp}"""
|
||||
positioning_block = f"""=== ПОЗИЦИОНИРОВАНИЕ ===
|
||||
Кому помогаем: {positioning_who}
|
||||
Какую проблему решаем: {positioning_problem}
|
||||
Какой результат получает клиент: {positioning_result}
|
||||
Почему выбирают нас: {positioning_difference}"""
|
||||
tone_intro = "Ты — профессиональный копирайтер, создающий контент для организации."
|
||||
post_intro = f"Напиши пост для {platform_names.get(platform, 'соцсетей')} от имени организации «{org_name}»."
|
||||
system_msg = f"Ты профессиональный копирайтер. Пишешь контент для организации «{org_name}» в соответствии с её Tone of Voice и позиционированием."
|
||||
else:
|
||||
identity_name = client_name
|
||||
identity_role = client_role
|
||||
identity_block = f"""=== ЛИЧНОСТЬ И КОНТЕКСТ ===
|
||||
Имя: {client_name}
|
||||
Роль: {client_role}
|
||||
Сущность бренда: {brand_essence}
|
||||
УТП: {usp}"""
|
||||
positioning_block = f"""=== ПОЗИЦИОНИРОВАНИЕ ===
|
||||
Кому помогаю: {positioning_who}
|
||||
Какую проблему решаю: {positioning_problem}
|
||||
Какой результат получает клиент: {positioning_result}"""
|
||||
tone_intro = "Ты — профессиональный копирайтер-психолог, который глубоко понимает образ и стратегию личного бренда."
|
||||
post_intro = f"Напиши пост для {platform_names.get(platform, 'соцсетей')} от лица автора медиа-образа."
|
||||
system_msg = "Ты профессиональный копирайтер-психолог. Пишешь в точном соответствии с образом и Tone of Voice автора."
|
||||
|
||||
if self.use_piapi:
|
||||
try:
|
||||
prompt = f"""
|
||||
{tone_intro}
|
||||
|
||||
{post_intro}
|
||||
|
||||
{identity_block}
|
||||
|
||||
{positioning_block}
|
||||
|
||||
=== TONE OF VOICE ===
|
||||
Описание тона: {tov_description}
|
||||
Референсы: {tov_references}
|
||||
Оси ToV: {tov_axes}
|
||||
|
||||
=== СТРУКТУРА МЫСЛИ ===
|
||||
{prompt_structure}
|
||||
|
||||
=== КОНТЕНТ-НАПРАВЛЕНИЯ ===
|
||||
{prompt_directions}
|
||||
|
||||
=== ФОРМАТЫ И ШАБЛОНЫ ===
|
||||
{prompt_formats}
|
||||
|
||||
=== ТЕХНИЧЕСКИЕ ТРЕБОВАНИЯ ===
|
||||
{prompt_technical}
|
||||
|
||||
=== ПАРАМЕТРЫ ЭТОГО ПОСТА ===
|
||||
Платформа: {platform_names.get(platform, 'соцсети')}
|
||||
Стиль: {style}
|
||||
Пожелания: {wishes}
|
||||
|
||||
Напиши только текст поста, без лишних пояснений. Следуй структуре мысли и Tone of Voice.
|
||||
"""
|
||||
response = requests.post(
|
||||
self.chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_msg},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.9,
|
||||
"max_tokens": 4000
|
||||
},
|
||||
proxies=self.proxies,
|
||||
timeout=90
|
||||
)
|
||||
response.raise_for_status()
|
||||
msg = response.json()["choices"][0]["message"]
|
||||
post = (msg.get("content") or "").strip()
|
||||
if post and len(post) > 20:
|
||||
print("✅ Пост из медиа-образа сгенерирован через API!")
|
||||
return post
|
||||
# Если content пустой — возможно reasoning-модель не успела завершить
|
||||
raise RuntimeError("API вернул пустой ответ (content пуст). Увеличьте max_tokens или попробуйте другую модель.")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
status = e.response.status_code if e.response is not None else "?"
|
||||
body = e.response.text[:200] if e.response is not None else ""
|
||||
raise RuntimeError(f"API вернул ошибку {status}: {body}")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise RuntimeError(f"Не удалось подключиться к API: {e}")
|
||||
except requests.exceptions.Timeout:
|
||||
raise RuntimeError("Превышен таймаут запроса к API (30 сек)")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Ошибка API: {e}")
|
||||
|
||||
# Fallback — шаблонный пост с данными медиа-образа
|
||||
print("🔄 Использую шаблонный пост из медиа-образа...")
|
||||
return self._generate_template_brand_post(brand_data, platform, wishes)
|
||||
|
||||
def modify_post(self, original_post: str, instruction: str) -> str:
|
||||
"""Модифицирует существующий пост по инструкции пользователя.
|
||||
original_post — текущий текст поста
|
||||
instruction — что нужно сделать («сократи вдвое», «сделай официальнее» и т.д.)
|
||||
Возвращает новый текст поста.
|
||||
"""
|
||||
print(f"🔄 Модификация поста: «{instruction[:60]}»...")
|
||||
|
||||
prompt = f"""Ты — профессиональный копирайтер. Пользователь просит изменить пост.
|
||||
|
||||
=== ТЕКУЩИЙ ПОСТ ===
|
||||
{original_post}
|
||||
|
||||
=== ИНСТРУКЦИЯ ===
|
||||
{instruction}
|
||||
|
||||
=== ЗАДАЧА ===
|
||||
Перепиши пост, выполнив инструкцию. Сохрани общий смысл и стиль. Верни ТОЛЬКО готовый пост без пояснений и markdown."""
|
||||
|
||||
if self.use_piapi:
|
||||
try:
|
||||
response = requests.post(
|
||||
self.chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты профессиональный копирайтер. Ты редактируешь посты строго по инструкции."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4000
|
||||
},
|
||||
proxies=self.proxies,
|
||||
timeout=90
|
||||
)
|
||||
response.raise_for_status()
|
||||
post = response.json()["choices"][0]["message"]["content"].strip()
|
||||
if post and len(post) > 20:
|
||||
print("✅ Пост модифицирован!")
|
||||
return post
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка API при модификации: {e}")
|
||||
|
||||
# Если ИИ не сработал — возвращаем оригинал с пометкой
|
||||
return f"{original_post}\n\n💬 [ИИ не смог изменить пост: {instruction}]"
|
||||
|
||||
def _generate_template_brand_post(self, brand_data, platform, wishes):
|
||||
"""Простой пост из медиа-образа без обращения к ИИ (запасной вариант)."""
|
||||
client_name = brand_data.get("client_name", "Автор")
|
||||
brand_essence = brand_data.get("brand_essence", "")
|
||||
usp = brand_data.get("usp", "")
|
||||
positioning_result = brand_data.get("positioning_result", "")
|
||||
|
||||
platform_emoji = {
|
||||
"instagram": "📸", "linkedin": "💼", "telegram": "📱",
|
||||
"twitter": "🐦", "ad": "📢"
|
||||
}
|
||||
emoji = platform_emoji.get(platform, "📝")
|
||||
|
||||
post = (
|
||||
f"{emoji} {brand_essence}\n\n"
|
||||
f"Меня зовут {client_name}.\n"
|
||||
f"{usp}\n\n"
|
||||
f"Я помогаю получить: {positioning_result}\n\n"
|
||||
)
|
||||
if wishes:
|
||||
post += f"💭 {wishes}\n\n"
|
||||
post += "Напишите в комментариях, какая у вас сейчас задача 👇\n\n#личныйбренд #бизнес"
|
||||
return post
|
||||
|
||||
def suggest_image_concepts(self, post_text, brand_data=None):
|
||||
"""Анализирует текст поста и предлагает 4 варианта концепции изображения.
|
||||
Возвращает список dict: [{title, description, style, size}, ...]"""
|
||||
print(f"🔄 Анализ поста для подбора концепции изображения ({len(post_text)} символов)...")
|
||||
|
||||
if not self.use_piapi:
|
||||
# Fallback — базовые концепции без ИИ
|
||||
return [
|
||||
{"title": "Тематическая фотография", "description": "Профессиональная фотография, иллюстрирующая ключевую идею поста", "style": "photo", "size": "wide"},
|
||||
{"title": "Минималистичный баннер", "description": "Чистый баннер с текстом поста на минималистичном фоне", "style": "minimal", "size": "wide"},
|
||||
{"title": "Абстрактная иллюстрация", "description": "Цветная абстрактная иллюстрация в тон поста", "style": "illustration", "size": "square"},
|
||||
{"title": "Инфографика", "description": "Визуализация данных и ключевых тезисов поста", "style": "flat", "size": "tall"},
|
||||
]
|
||||
|
||||
brand_context = ""
|
||||
if brand_data:
|
||||
brand_context = f"""
|
||||
Контекст бренда:
|
||||
- Имя: {brand_data.get('client_name', '')}
|
||||
- Роль: {brand_data.get('client_role', '')}
|
||||
- Сущность: {brand_data.get('brand_essence', '')}
|
||||
- УТП: {brand_data.get('usp', '')}
|
||||
"""
|
||||
|
||||
prompt = f"""Ты — арт-директор и визуальный стратег. Проанализируй текст поста и предложи 4 разных варианта визуального оформления.
|
||||
|
||||
=== ТЕКСТ ПОСТА ===
|
||||
{post_text}
|
||||
{brand_context}
|
||||
=== ЗАДАЧА ===
|
||||
Предложи 4 контрастных варианта изображения. Каждый вариант — это отдельная идея с уникальным настроением и стилем.
|
||||
|
||||
Для каждого варианта укажи:
|
||||
- title: краткое название (2-4 слова на русском)
|
||||
- description: что изображено (1 предложение на русском)
|
||||
- style: один из: photo, illustration, minimal, 3d, flat
|
||||
- size: один из: square, wide, tall
|
||||
|
||||
Верни ТОЛЬКО JSON-массив без markdown и пояснений:
|
||||
[{{"title": "...", "description": "...", "style": "...", "size": "..."}}, ...]"""
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты — арт-директор. Отвечай только валидным JSON без markdown-обёрток."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.8,
|
||||
"max_tokens": 1000
|
||||
},
|
||||
proxies=self.proxies,
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
raw = response.json()["choices"][0]["message"]["content"].strip()
|
||||
|
||||
# Убираем markdown-обёртку если есть
|
||||
if raw.startswith("```"):
|
||||
raw = raw.split("\n", 1)[1]
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3]
|
||||
raw = raw.strip()
|
||||
|
||||
concepts = json.loads(raw)
|
||||
if isinstance(concepts, list) and len(concepts) > 0:
|
||||
print(f"✅ Получено {len(concepts)} концепций от ИИ")
|
||||
return concepts[:5] # макс 5
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка при получении концепций: {e}")
|
||||
|
||||
# Fallback
|
||||
return [
|
||||
{"title": "Тематическая фотография", "description": "Профессиональная фотография, иллюстрирующая ключевую идею поста", "style": "photo", "size": "wide"},
|
||||
{"title": "Минималистичный баннер", "description": "Чистый баннер с текстом поста на минималистичном фоне", "style": "minimal", "size": "wide"},
|
||||
{"title": "Абстрактная иллюстрация", "description": "Цветная абстрактная иллюстрация в тон поста", "style": "illustration", "size": "square"},
|
||||
{"title": "Инфографика", "description": "Визуализация данных и ключевых тезисов поста", "style": "flat", "size": "tall"},
|
||||
]
|
||||
|
||||
def parse_brand_doc_to_survey(self, doc_text):
|
||||
"""Разбирает текст .docx документа стратегии личного бренда
|
||||
и извлекает структурированные поля опроса через PiAPI.
|
||||
Возвращает dict с полями brand_survey.data или None при ошибке."""
|
||||
print(f"🔄 Разбор документа медиа-образа ({len(doc_text)} символов)...")
|
||||
|
||||
if not self.use_piapi:
|
||||
print("⚠️ PiAPI недоступен — разбор документа невозможен")
|
||||
return None
|
||||
|
||||
# Ограничиваем длину, чтобы влезть в контекст
|
||||
text = doc_text[:12000]
|
||||
|
||||
prompt = f"""
|
||||
Ты — аналитик. Извлеки структурированные данные из документа стратегии личного бренда и верни их в виде JSON.
|
||||
|
||||
Документ:
|
||||
---
|
||||
{text}
|
||||
---
|
||||
|
||||
Извлеки следующие поля (если поле отсутствует в документе — оставь пустую строку ""):
|
||||
|
||||
{{
|
||||
"client_name": "Имя клиента",
|
||||
"client_role": "Профессия/роль",
|
||||
"client_location": "Город/регион",
|
||||
"current_situation": "Описание текущей ситуации",
|
||||
"goal_audience_number": "число подписчиков",
|
||||
"goal_audience_platform": "основная платформа",
|
||||
"goal_audience_description": "описание аудитории",
|
||||
"goal_expert_field": "область экспертизы",
|
||||
"goal_expert_speaking": "число выступлений в год",
|
||||
"goal_commercial_leads": "число заявок в месяц",
|
||||
"goal_commercial_services": "услуги",
|
||||
"goal_content_system": "описание контент-системы",
|
||||
"brand_essence": "сущность бренда одной строкой",
|
||||
"positioning_who": "кому помогаю",
|
||||
"positioning_problem": "какую проблему решаю",
|
||||
"positioning_result": "какой результат получает клиент",
|
||||
"usp": "УТП",
|
||||
"ideal_audiences": "идеальные подписчики, по строкам",
|
||||
"visual_concept": "концепция визуала",
|
||||
"color_main_name": "название основного цвета",
|
||||
"color_main_hex": "HEX основного цвета",
|
||||
"color_accent1_name": "название акцентного цвета 1",
|
||||
"color_accent1_hex": "HEX акцентного цвета 1",
|
||||
"color_accent2_name": "название акцентного цвета 2",
|
||||
"color_accent2_hex": "HEX акцентного цвета 2",
|
||||
"color_neutral": "нейтральная база",
|
||||
"typography_headings": "шрифт заголовков",
|
||||
"typography_body": "шрифт текста",
|
||||
"typography_accents": "шрифт акцентов",
|
||||
"tov_description": "описание тона",
|
||||
"tov_references": "референсы",
|
||||
"tov_axes": "оси ToV по строкам",
|
||||
"pillar1_name": "название столпа 1",
|
||||
"pillar1_goal": "цель столпа 1",
|
||||
"pillar1_formats": "форматы столпа 1 по строкам",
|
||||
"pillar1_frequency": "частота столпа 1",
|
||||
"pillar2_name": "название столпа 2",
|
||||
"pillar2_goal": "цель столпа 2",
|
||||
"pillar2_formats": "форматы столпа 2 по строкам",
|
||||
"pillar2_frequency": "частота столпа 2",
|
||||
"pillar3_name": "название столпа 3",
|
||||
"pillar3_goal": "цель столпа 3",
|
||||
"pillar3_formats": "форматы столпа 3 по строкам",
|
||||
"pillar3_frequency": "частота столпа 3",
|
||||
"special_formats": "особые форматы",
|
||||
"product_lead_magnet": "лид-магнит",
|
||||
"product_simple": "простая услуга",
|
||||
"product_converter": "услуга-конвертер",
|
||||
"product_complex": "комплексные решения",
|
||||
"product_expert": "экспертные продукты",
|
||||
"metrics_quantitative": "количественные метрики, формат: Название | Значение | Измерение, по строкам",
|
||||
"metrics_qualitative": "качественные метрики, тот же формат",
|
||||
"metrics_commercial": "коммерческие метрики, тот же формат",
|
||||
"risks": "риски, формат: Риск | Решение, по строкам",
|
||||
"recommendations": "рекомендации по строкам",
|
||||
"archetype_key_name": "название ключевого архетипа",
|
||||
"archetype_key_essence": "суть ключевого архетипа",
|
||||
"archetype_key_manifestations": "проявления по строкам",
|
||||
"archetype_supporting": "вспомогательные архетипы, формат: Название | Суть | Проявления, по строкам",
|
||||
"archetype_synthesis": "синтез архетипов",
|
||||
"archetype_table": "таблица проявлений, формат: Ситуация | Архетип | Описание, по строкам",
|
||||
"prompt_context": "контекст и личность для промпта",
|
||||
"prompt_tov": "Tone of Voice для промпта",
|
||||
"prompt_structure": "структура мысли",
|
||||
"prompt_directions": "контент-направления по строкам",
|
||||
"prompt_formats": "форматы и шаблоны",
|
||||
"prompt_technical": "технические требования к тексту"
|
||||
}}
|
||||
|
||||
Верни ТОЛЬКО валидный JSON без пояснений и markdown.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты отвечаешь только валидным JSON, без markdown и пояснений."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 3000,
|
||||
"response_format": {"type": "json_object"}
|
||||
},
|
||||
proxies=self.proxies,
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||
parsed = json.loads(content)
|
||||
print("✅ Документ медиа-образа разобран через PiAPI!")
|
||||
# Гарантируем наличие channels и funnel
|
||||
if "channels" not in parsed:
|
||||
parsed["channels"] = []
|
||||
if "funnel" not in parsed:
|
||||
parsed["funnel"] = []
|
||||
return parsed
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка разбора документа: {e}")
|
||||
return None
|
||||
|
||||
def generate_content_plan(self, business_type, business_description, days):
|
||||
"""Генерирует контент-план на несколько дней вперёд"""
|
||||
print(f"🔄 Генерация контент-плана на {days} дн...")
|
||||
@@ -263,10 +752,10 @@ class ContentGenerator:
|
||||
"""
|
||||
|
||||
response = requests.post(
|
||||
PIAPI_URL,
|
||||
self.chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {PIAPI_API_KEY}"
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
@@ -278,7 +767,7 @@ class ContentGenerator:
|
||||
"max_tokens": 1000,
|
||||
"response_format": {"type": "json_object"}
|
||||
},
|
||||
proxies=get_proxies(),
|
||||
proxies=self.proxies,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -294,6 +783,100 @@ class ContentGenerator:
|
||||
print("🔄 Использую шаблонный план...")
|
||||
return self._generate_template_plan(business_type, days)
|
||||
|
||||
def generate_content_plan_from_brand_profile(self, brand_data, days):
|
||||
"""Генерирует контент-план на основе медиа-образа."""
|
||||
print(f"🔄 Генерация контент-плана из медиа-образа на {days} дн...")
|
||||
|
||||
brand_type = brand_data.get("brand_type", "personal")
|
||||
is_org = (brand_type == "organization")
|
||||
is_community = (brand_type == "community")
|
||||
|
||||
client_name = brand_data.get("client_name", "")
|
||||
org_name = brand_data.get("org_name", "")
|
||||
brand_essence = brand_data.get("brand_essence", "")
|
||||
positioning_who = brand_data.get("positioning_who", "")
|
||||
usp = brand_data.get("usp", "")
|
||||
tov_description = brand_data.get("tov_description", "")
|
||||
prompt_directions = brand_data.get("prompt_directions", "")
|
||||
pillar1_formats = brand_data.get("pillar1_formats", "")
|
||||
pillar2_formats = brand_data.get("pillar2_formats", "")
|
||||
pillar3_formats = brand_data.get("pillar3_formats", "")
|
||||
|
||||
if is_community:
|
||||
entity_name = org_name
|
||||
pillar1_label = "О миссии"
|
||||
pillar2_label = "Мероприятия"
|
||||
pillar3_label = "Участники"
|
||||
entity_type = "Бизнес-сообщество / общественная организация"
|
||||
elif is_org:
|
||||
entity_name = org_name
|
||||
pillar1_label = "О продукте"
|
||||
pillar2_label = "Экспертиза"
|
||||
pillar3_label = "Вовлечение"
|
||||
entity_type = "Организация"
|
||||
else:
|
||||
entity_name = client_name
|
||||
pillar1_label = "Кто я"
|
||||
pillar2_label = "Решаю боль"
|
||||
pillar3_label = "Втягиваю"
|
||||
entity_type = "Автор"
|
||||
|
||||
if self.use_piapi:
|
||||
try:
|
||||
prompt = f"""
|
||||
Ты — SMM-стратег. Составь контент-план на {days} дней.
|
||||
|
||||
{entity_type}: {entity_name}
|
||||
Сущность бренда: {brand_essence}
|
||||
УТП: {usp}
|
||||
Аудитория: {positioning_who}
|
||||
Tone of Voice: {tov_description}
|
||||
|
||||
Контент-направления:
|
||||
{prompt_directions}
|
||||
|
||||
Три столпа контента:
|
||||
1. «{pillar1_label}» (форматы): {pillar1_formats}
|
||||
2. «{pillar2_label}» (форматы): {pillar2_formats}
|
||||
3. «{pillar3_label}» (форматы): {pillar3_formats}
|
||||
|
||||
Для каждого дня укажи платформу (instagram, linkedin, telegram, twitter или ad) и короткую идею поста (1 предложение), учитывая три столпа и Tone of Voice.
|
||||
|
||||
Верни ТОЛЬКО валидный JSON без пояснений в формате:
|
||||
{{"plan": [{{"day": 1, "platform": "instagram", "idea": "..."}}, ...]}}
|
||||
"""
|
||||
response = requests.post(
|
||||
self.chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}"
|
||||
},
|
||||
json={
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты отвечаешь только валидным JSON, без markdown и пояснений."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 1200,
|
||||
"response_format": {"type": "json_object"}
|
||||
},
|
||||
proxies=self.proxies,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||
parsed = json.loads(content)
|
||||
plan = parsed.get("plan")
|
||||
if plan:
|
||||
print("✅ Контент-план из медиа-образа сгенерирован через PiAPI!")
|
||||
return plan
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка PiAPI: {e}, использую шаблонный план")
|
||||
|
||||
print("🔄 Использую шаблонный план...")
|
||||
return self._generate_template_plan(brand_essence or client_name or "бизнес", days)
|
||||
|
||||
def _generate_template_plan(self, business_type, days):
|
||||
"""Простой контент-план без обращения к ИИ (запасной вариант)"""
|
||||
platforms = ['instagram', 'linkedin', 'telegram', 'twitter', 'ad']
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Анализатор профилей соцсетей.
|
||||
Определяет платформу по URL, извлекает посты, отправляет на AI-анализ
|
||||
для заполнения полей медиа-образа.
|
||||
"""
|
||||
|
||||
import re
|
||||
import json
|
||||
import requests
|
||||
from config import get_proxies
|
||||
|
||||
|
||||
# === Определение платформы ===
|
||||
|
||||
def detect_platform(url: str) -> dict:
|
||||
"""Определяет платформу по URL.
|
||||
Возвращает {"platform": str, "channel": str, "fetchable": bool}
|
||||
"""
|
||||
url = url.strip()
|
||||
|
||||
# Telegram: t.me/channel_name или t.me/s/channel_name
|
||||
m = re.match(r'https?://t\.me/(?:s/)?([a-zA-Z0-9_]+)', url)
|
||||
if m:
|
||||
return {"platform": "telegram", "channel": m.group(1), "fetchable": True}
|
||||
|
||||
# VK: vk.com/username or vk.ru/username
|
||||
m = re.match(r'https?://(?:vk\.com|vk\.ru)/([a-zA-Z0-9_.]+)', url)
|
||||
if m:
|
||||
return {"platform": "vk", "channel": m.group(1), "fetchable": False}
|
||||
|
||||
# Tenchat: tenchat.ru/username
|
||||
m = re.match(r'https?://tenchat\.ru/([a-zA-Z0-9_]+)', url)
|
||||
if m:
|
||||
return {"platform": "tenchat", "channel": m.group(1), "fetchable": False}
|
||||
|
||||
# Instagram
|
||||
m = re.match(r'https?://(?:www\.)?instagram\.com/([a-zA-Z0-9_.]+)/?', url)
|
||||
if m:
|
||||
return {"platform": "instagram", "channel": m.group(1), "fetchable": False}
|
||||
|
||||
return {"platform": "unknown", "channel": url, "fetchable": False}
|
||||
|
||||
|
||||
# === Парсинг Telegram ===
|
||||
|
||||
def fetch_telegram_posts(channel_name: str, limit: int = 20) -> list:
|
||||
"""Загружает посты из публичного Telegram-канала через t.me/s/.
|
||||
Возвращает список {"text": str, "date": str}.
|
||||
"""
|
||||
url = f"https://t.me/s/{channel_name}"
|
||||
proxies = get_proxies()
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
try:
|
||||
r = requests.get(url, proxies=proxies, timeout=15, headers=headers)
|
||||
r.raise_for_status()
|
||||
except Exception as e:
|
||||
print(f"[SocialAnalyzer] Ошибка загрузки Telegram: {e}")
|
||||
return []
|
||||
|
||||
html = r.text
|
||||
|
||||
# Извлекаем тексты постов
|
||||
texts = re.findall(
|
||||
r'class="tgme_widget_message_text[^"]*"[^>]*>(.*?)</div>',
|
||||
html, re.DOTALL
|
||||
)
|
||||
|
||||
# Извлекаем даты
|
||||
dates = re.findall(
|
||||
r'class="tgme_widget_message_date"[^>]*>.*?<time[^>]*datetime="([^"]*)"',
|
||||
html, re.DOTALL
|
||||
)
|
||||
|
||||
posts = []
|
||||
for i, raw_text in enumerate(texts[:limit]):
|
||||
clean = re.sub(r'<[^>]+>', ' ', raw_text).strip()
|
||||
clean = re.sub(r'\s+', ' ', clean)
|
||||
if len(clean) > 10: # Пропускаем служебные сообщения
|
||||
date = dates[i] if i < len(dates) else ""
|
||||
posts.append({"text": clean, "date": date})
|
||||
|
||||
print(f"[SocialAnalyzer] Telegram: загружено {len(posts)} постов из @{channel_name}")
|
||||
return posts
|
||||
|
||||
|
||||
# === AI-анализ постов ===
|
||||
|
||||
ANALYSIS_PROMPT_TEMPLATE = """
|
||||
Ты — эксперт по брендингу и SMM. Проанализируй {count} постов из профиля/канала и извлеки параметры для медиа-образа.
|
||||
|
||||
=== ПОСТЫ ДЛЯ АНАЛИЗА ===
|
||||
{posts_text}
|
||||
|
||||
=== ЗАДАЧА ===
|
||||
Проанализируй стиль, тон, темы, подачу и структуру постов. Верни JSON со следующими полями:
|
||||
|
||||
{{
|
||||
"client_name": "Имя или название канала/профиля (если можно определить)",
|
||||
"brand_essence": "Сущность бренда одной строкой (что этот человек/бренд транслирует)",
|
||||
"tov_description": "Описание Tone of Voice (тон общения, манера подачи)",
|
||||
"tov_axes": "Оси ToV (по строкам, 3-5 шт)",
|
||||
"tov_references": "На кого/что похож стиль (референсы)",
|
||||
"prompt_directions": "Контент-направления (по строкам, какие темы затрагиваются)",
|
||||
"prompt_structure": "Типичная структура постов (как строются тексты)",
|
||||
"prompt_formats": "Типичные форматы постов (экспертный, личный, вовлекающий и т.д.)",
|
||||
"current_situation": "Краткое описание: кто этот человек/бренд, чем занимается",
|
||||
"client_role": "Роль / профессия (если определяется)",
|
||||
"positioning_who": "Для кого контент (целевая аудитория)",
|
||||
"positioning_problem": "Какую проблему решает",
|
||||
"positioning_result": "Какой результат получает аудитория"
|
||||
}}
|
||||
|
||||
Верни ТОЛЬКО валидный JSON без markdown и пояснений.
|
||||
"""
|
||||
|
||||
|
||||
def analyze_posts_with_ai(posts: list, provider_settings: dict = None) -> dict:
|
||||
"""Отправляет посты на AI-анализ и возвращает структурированные данные
|
||||
для заполнения медиа-образа.
|
||||
"""
|
||||
if not posts:
|
||||
return {"error": "Нет постов для анализа"}
|
||||
|
||||
# Склеиваем посты
|
||||
posts_text = ""
|
||||
for i, p in enumerate(posts[:20], 1):
|
||||
date_str = f" ({p['date']})" if p.get("date") else ""
|
||||
posts_text += f"\n--- Пост {i}{date_str} ---\n{p['text']}\n"
|
||||
|
||||
prompt = ANALYSIS_PROMPT_TEMPLATE.format(
|
||||
count=len(posts[:20]),
|
||||
posts_text=posts_text
|
||||
)
|
||||
|
||||
# Определяем AI-провайдер
|
||||
if provider_settings and provider_settings.get("api_key"):
|
||||
api_key = provider_settings["api_key"]
|
||||
base_url = provider_settings.get("base_url", "https://api.piapi.ai/v1")
|
||||
model = provider_settings.get("text_model", "gpt-4o")
|
||||
else:
|
||||
from config import PIAPI_API_KEY, PIAPI_MODEL
|
||||
api_key = PIAPI_API_KEY
|
||||
base_url = "https://api.piapi.ai/v1"
|
||||
model = PIAPI_MODEL
|
||||
|
||||
if not api_key:
|
||||
return {"error": "AI-провайдер не настроен. Настройте ИИ в разделе «ИИ»."}
|
||||
|
||||
chat_url = base_url.rstrip("/") + "/chat/completions"
|
||||
proxies = get_proxies()
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
chat_url,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}"
|
||||
},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты эксперт по брендингу. Отвечаешь только валидным JSON."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 2000,
|
||||
"response_format": {"type": "json_object"}
|
||||
},
|
||||
proxies=proxies,
|
||||
timeout=90
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||
|
||||
# Парсим JSON
|
||||
result = json.loads(content)
|
||||
print(f"[SocialAnalyzer] AI-анализ завершён, получено {len(result)} полей")
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[SocialAnalyzer] Ошибка парсинга JSON от AI: {e}")
|
||||
return {"error": "AI вернул некорректный JSON. Попробуйте ещё раз."}
|
||||
except Exception as e:
|
||||
print(f"[SocialAnalyzer] Ошибка AI: {e}")
|
||||
return {"error": f"Ошибка AI: {e}"}
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Публикация постов в Telegram-каналы и чаты через Bot API.
|
||||
|
||||
Использует TOKEN из config — тот же токен, что и у бота.
|
||||
Бот должен быть админом в канале/чате, куда публикуем."""
|
||||
|
||||
import re
|
||||
import requests
|
||||
|
||||
from config import TOKEN, get_proxies
|
||||
|
||||
TELEGRAM_API = f"https://api.telegram.org/bot{TOKEN}"
|
||||
|
||||
|
||||
def _md_to_html(text: str) -> str:
|
||||
"""Конвертирует простой Markdown в HTML для Telegram.
|
||||
|
||||
Поддерживает: **жирный**, *курсив*, __подчёркнутый~~зачёркнутый~~,
|
||||
`моноширинный`, ```блок кода```, заголовки ### в <b>,
|
||||
нумерованные/маркированные списки.
|
||||
Telegram HTML: <b>, <i>, <u>, <s>, <code>, <pre>, <a>.
|
||||
"""
|
||||
# Сначала экранируем HTML-символы в тексте (но не наши теги)
|
||||
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
# Блоки кода ```...```
|
||||
text = re.sub(r'```(\w*)\n(.*?)```', r'<pre>\2</pre>', text, flags=re.DOTALL)
|
||||
text = re.sub(r'```(.*?)```', r'<pre>\1</pre>', text, flags=re.DOTALL)
|
||||
|
||||
# Inline code `...`
|
||||
text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
|
||||
|
||||
# Заголовки ### **Текст** → <b>Текст</b>
|
||||
text = re.sub(r'^#{1,6}\s+\*\*(.+?)\*\*\s*$', r'<b>\1</b>', text, flags=re.MULTILINE)
|
||||
text = re.sub(r'^#{1,6}\s+(.+?)\s*$', r'<b>\1</b>', text, flags=re.MULTILINE)
|
||||
|
||||
# Жирный **...**
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
||||
|
||||
# Курсив *...* (но не внутри <b> тегов)
|
||||
text = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'<i>\1</i>', text)
|
||||
|
||||
# Подчёркнутый __...__
|
||||
text = re.sub(r'__(.+?)__', r'<u>\1</u>', text)
|
||||
|
||||
# Зачёркнутый ~~...~~
|
||||
text = re.sub(r'~~(.+?)~~', r'<s>\1</s>', text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _api_call(method, params):
|
||||
"""Вызывает Telegram Bot API метод. Возвращает (ok, result_or_error)."""
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{TELEGRAM_API}/{method}",
|
||||
data=params,
|
||||
proxies=get_proxies(),
|
||||
timeout=30,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("ok"):
|
||||
return True, data.get("result", {})
|
||||
return False, data.get("description", "Неизвестная ошибка")
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def resolve_chat(username_or_id):
|
||||
"""Разрешает @username или числовой ID в информацию о чате.
|
||||
Возвращает dict: {chat_id, title, username, type} или {error}."""
|
||||
# Нормализуем
|
||||
identifier = username_or_id.strip()
|
||||
if identifier.startswith("@"):
|
||||
identifier = identifier # оставляем как есть для API
|
||||
elif identifier.lstrip("-").isdigit():
|
||||
identifier = int(identifier)
|
||||
|
||||
ok, result = _api_call("getChat", {"chat_id": identifier})
|
||||
if not ok:
|
||||
return {"error": result}
|
||||
|
||||
chat_id = result.get("id")
|
||||
chat_type = result.get("type", "unknown")
|
||||
title = result.get("title") or result.get("first_name", "") or result.get("username", "")
|
||||
username = result.get("username", "")
|
||||
|
||||
return {
|
||||
"chat_id": chat_id,
|
||||
"title": title,
|
||||
"username": username,
|
||||
"type": chat_type,
|
||||
}
|
||||
|
||||
|
||||
def check_bot_is_admin(chat_id):
|
||||
"""Проверяет, является ли бот админом в чате/канале.
|
||||
Возвращает (is_admin, error_message)."""
|
||||
ok, result = _api_call("getChatMember", {
|
||||
"chat_id": chat_id,
|
||||
"user_id": _get_bot_id(),
|
||||
})
|
||||
if not ok:
|
||||
return False, f"Не удалось проверить статус: {result}"
|
||||
|
||||
status = result.get("status", "")
|
||||
# administrator — для чатов, owner — для каналов
|
||||
if status in ("administrator", "creator", "owner"):
|
||||
return True, None
|
||||
return False, f"Бот не админ в этом чате (статус: {status}). Добавьте бота в админы."
|
||||
|
||||
|
||||
def _get_bot_id():
|
||||
"""Возвращает ID бота (кешируется)."""
|
||||
global _cached_bot_id
|
||||
if _cached_bot_id is not None:
|
||||
return _cached_bot_id
|
||||
ok, result = _api_call("getMe", {})
|
||||
if ok:
|
||||
_cached_bot_id = result.get("id")
|
||||
return _cached_bot_id
|
||||
return None
|
||||
|
||||
|
||||
_cached_bot_id = None
|
||||
|
||||
|
||||
def publish_post(chat_id, text, image_bytes=None):
|
||||
"""Публикует пост в канал/чат.
|
||||
- text: текст поста (Markdown конвертируется в HTML)
|
||||
- image_bytes: опционально, байты картинки
|
||||
Возвращает (ok, message_or_message_id)."""
|
||||
text = _md_to_html(text)
|
||||
if image_bytes:
|
||||
ok, result = _api_call_send_photo(chat_id, image_bytes, text)
|
||||
else:
|
||||
ok, result = _api_call("sendMessage", {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
})
|
||||
|
||||
if ok:
|
||||
msg_id = result.get("message_id")
|
||||
return True, f"Опубликовано (message_id={msg_id})"
|
||||
return False, result
|
||||
|
||||
|
||||
def _api_call_send_photo(chat_id, image_bytes, caption):
|
||||
"""Отправляет фото с подписью.
|
||||
Если текст длиннее 1024 символов — фото без подписи + текст отдельным сообщением."""
|
||||
long_text = len(caption) > 1024
|
||||
try:
|
||||
files = {"photo": ("image.png", image_bytes, "image/png")}
|
||||
data = {
|
||||
"chat_id": chat_id,
|
||||
"caption": "" if long_text else caption,
|
||||
"parse_mode": "HTML" if not long_text else None,
|
||||
}
|
||||
# Убираем None из data
|
||||
data = {k: v for k, v in data.items() if v is not None}
|
||||
resp = requests.post(
|
||||
f"{TELEGRAM_API}/sendPhoto",
|
||||
data=data,
|
||||
files=files,
|
||||
proxies=get_proxies(),
|
||||
timeout=60,
|
||||
)
|
||||
data = resp.json()
|
||||
if not data.get("ok"):
|
||||
return False, data.get("description", "Ошибка отправки фото")
|
||||
|
||||
# Если текст длинный — отправляем его отдельным сообщением
|
||||
if long_text:
|
||||
ok2, result2 = _api_call("sendMessage", {
|
||||
"chat_id": chat_id,
|
||||
"text": caption,
|
||||
"parse_mode": "HTML",
|
||||
})
|
||||
if not ok2:
|
||||
# Фото ушло, но текст не отправился — всё равно считаем успехом,
|
||||
# но возвращаем предупреждение
|
||||
return True, data.get("result", {})
|
||||
|
||||
return True, data.get("result", {})
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
+944
-44
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,312 @@
|
||||
"""Генерация .docx документа стратегии личного бренда из данных опроса."""
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor, Inches
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
|
||||
def _split_lines(text):
|
||||
"""Разбивает текст на строки, удаляя пустые."""
|
||||
if not text:
|
||||
return []
|
||||
return [line.strip() for line in text.strip().split("\n") if line.strip()]
|
||||
|
||||
|
||||
def _split_pipe(text):
|
||||
"""Разбивает строки по символу '|' на колонки."""
|
||||
rows = []
|
||||
for line in _split_lines(text):
|
||||
cols = [c.strip() for c in line.split("|")]
|
||||
rows.append(cols)
|
||||
return rows
|
||||
|
||||
|
||||
def _add_table(doc, headers, rows):
|
||||
"""Добавляет таблицу с заголовками и строками."""
|
||||
table = doc.add_table(rows=1, cols=len(headers))
|
||||
table.style = "Table Grid"
|
||||
hdr = table.rows[0].cells
|
||||
for i, h in enumerate(headers):
|
||||
hdr[i].text = h
|
||||
for row_data in rows:
|
||||
cells = table.add_row().cells
|
||||
for i, val in enumerate(row_data):
|
||||
cells[i].text = val if i < len(row_data) else ""
|
||||
doc.add_paragraph()
|
||||
|
||||
|
||||
def generate_brand_docx(data: dict) -> bytes:
|
||||
"""Генерирует .docx документ стратегии личного бренда или организации.
|
||||
data — dict с полями опроса.
|
||||
Возвращает байты документа."""
|
||||
doc = Document()
|
||||
|
||||
brand_type = data.get("brand_type", "personal")
|
||||
is_org = (brand_type == "organization")
|
||||
is_community = (brand_type == "community")
|
||||
|
||||
# === ТИТУЛ ===
|
||||
if is_community:
|
||||
entity_name = data.get("org_name", "")
|
||||
title = doc.add_heading(f"Стратегия развития бизнес-сообщества\n{entity_name}", 0)
|
||||
elif is_org:
|
||||
entity_name = data.get("org_name", "")
|
||||
title = doc.add_heading(f"Стратегия бренд-коммуникаций\n{entity_name}", 0)
|
||||
else:
|
||||
entity_name = data.get("client_name", "")
|
||||
title = doc.add_heading(f"Стратегия развития личного бренда\n{entity_name}", 0)
|
||||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
version = data.get("version", "1.0")
|
||||
date_str = data.get("date_str") or datetime.now().strftime("%B %Y")
|
||||
doc.add_paragraph(f"Версия {version} {date_str}")
|
||||
doc.add_paragraph()
|
||||
|
||||
# === 1. РЕЗЮМЕ И ЦЕЛИ ===
|
||||
doc.add_heading("1. Резюме и цели", level=1)
|
||||
|
||||
if is_community:
|
||||
doc.add_heading("Общественная организация:", level=3)
|
||||
doc.add_paragraph(f"Название: {data.get('org_name', '—')}")
|
||||
doc.add_paragraph(f"Сфера деятельности: {data.get('org_industry', '—')}")
|
||||
doc.add_paragraph(f"Миссия: {data.get('org_mission', '—')}")
|
||||
doc.add_paragraph(f"Направления деятельности: {data.get('org_products', '—')}")
|
||||
doc.add_paragraph(f"Участников: {data.get('org_team_size', '—')}")
|
||||
doc.add_paragraph(f"Год основания: {data.get('org_founded', '—')}")
|
||||
doc.add_paragraph(f"Формат участия: {data.get('org_format', '—')}")
|
||||
elif is_org:
|
||||
doc.add_heading("Организация:", level=3)
|
||||
doc.add_paragraph(f"Название: {data.get('org_name', '—')}")
|
||||
doc.add_paragraph(f"Сфера деятельности: {data.get('org_industry', '—')}")
|
||||
doc.add_paragraph(f"Миссия: {data.get('org_mission', '—')}")
|
||||
doc.add_paragraph(f"Продукты/услуги: {data.get('org_products', '—')}")
|
||||
doc.add_paragraph(f"Сотрудников: {data.get('org_team_size', '—')}")
|
||||
doc.add_paragraph(f"Год основания: {data.get('org_founded', '—')}")
|
||||
else:
|
||||
doc.add_heading("Текущая ситуация:", level=3)
|
||||
doc.add_paragraph(data.get("current_situation", "—"))
|
||||
|
||||
doc.add_heading("Ключевые цели на 12 месяцев:", level=3)
|
||||
|
||||
doc.add_paragraph(
|
||||
f"Качественный рост аудитории: Набрать {data.get('goal_audience_number', '—')} "
|
||||
f"подписчиков в основном {data.get('goal_audience_platform', '—')} "
|
||||
f"(активная аудитория: {data.get('goal_audience_description', '—')})."
|
||||
)
|
||||
doc.add_paragraph(
|
||||
f"Укрепление экспертного статуса: Стать признанным федеральным экспертом "
|
||||
f"в сфере {data.get('goal_expert_field', '—')} "
|
||||
f"(не менее {data.get('goal_expert_speaking', '—')} приглашений выступить "
|
||||
f"на федеральных площадках в год)."
|
||||
)
|
||||
doc.add_paragraph(
|
||||
f"Коммерциализация личного бренда: Обеспечить поток из не менее "
|
||||
f"{data.get('goal_commercial_leads', '—')} заявок в месяц на платные услуги "
|
||||
f"({data.get('goal_commercial_services', '—')}) через личный бренд."
|
||||
)
|
||||
doc.add_paragraph(
|
||||
f"Системный контент: {data.get('goal_content_system', '—')}"
|
||||
)
|
||||
|
||||
# === 2. ПОЗИЦИОНИРОВАНИЕ И УТП ===
|
||||
doc.add_heading("2. Позиционирование и УТП", level=1)
|
||||
|
||||
doc.add_heading("Сущность бренда:", level=3)
|
||||
doc.add_paragraph(data.get("brand_essence", "—"))
|
||||
|
||||
doc.add_heading("Позиционирование (Ответы на ключевые вопросы):", level=3)
|
||||
if is_community:
|
||||
doc.add_paragraph(f"Для кого создано: {data.get('positioning_who', '—')}")
|
||||
doc.add_paragraph(f"Какую проблему решает: {data.get('positioning_problem', '—')}")
|
||||
doc.add_paragraph(f"Ценность для участника: {data.get('positioning_result', '—')}")
|
||||
doc.add_paragraph(f"Чем отличаемся от других: {data.get('positioning_difference', '—')}")
|
||||
doc.add_paragraph(f"Форматы взаимодействия: {data.get('community_formats', '—')}")
|
||||
elif is_org:
|
||||
doc.add_paragraph(f"Кому помогаем: {data.get('positioning_who', '—')}")
|
||||
doc.add_paragraph(f"Какую проблему решаем: {data.get('positioning_problem', '—')}")
|
||||
doc.add_paragraph(f"Какой результат получает клиент: {data.get('positioning_result', '—')}")
|
||||
doc.add_paragraph(f"Почему выбирают нас: {data.get('positioning_difference', '—')}")
|
||||
else:
|
||||
doc.add_paragraph(f"Кому я помогаю? {data.get('positioning_who', '—')}")
|
||||
doc.add_paragraph(f"Какую проблему решаю? {data.get('positioning_problem', '—')}")
|
||||
doc.add_paragraph(f"Какой результат получает клиент? {data.get('positioning_result', '—')}")
|
||||
|
||||
doc.add_heading("Уникальное торговое предложение (УТП):", level=3)
|
||||
doc.add_paragraph(data.get("usp", "—"))
|
||||
|
||||
# === 3. АУДИТОРИИ И КАНАЛЫ ===
|
||||
doc.add_heading("3. Аудитории и каналы коммуникации", level=1)
|
||||
|
||||
doc.add_heading("Идеальные подписчики/клиенты:", level=3)
|
||||
for line in _split_lines(data.get("ideal_audiences", "")):
|
||||
doc.add_paragraph(line, style="List Bullet")
|
||||
|
||||
doc.add_heading("Карта каналов (по приоритету):", level=3)
|
||||
channels = data.get("channels", [])
|
||||
if channels:
|
||||
_add_table(doc, ["Канал", "Тип", "Частота", "Описание"], [
|
||||
[ch.get("platform", ""), ch.get("type", ""), ch.get("frequency", ""), ch.get("description", "")]
|
||||
for ch in channels
|
||||
])
|
||||
else:
|
||||
doc.add_paragraph("—")
|
||||
|
||||
# === 4. ВИЗУАЛЬНАЯ И ВЕРБАЛЬНАЯ ИДЕНТИЧНОСТЬ ===
|
||||
doc.add_heading("4. Визуальная и вербальная идентичность", level=1)
|
||||
|
||||
doc.add_heading("А. Визуальная идентичность", level=3)
|
||||
doc.add_paragraph(f"Концепция: {data.get('visual_concept', '—')}")
|
||||
|
||||
doc.add_paragraph("Цветовая палитра:")
|
||||
_add_table(doc, ["Роль", "Название", "HEX"], [
|
||||
["Основной", data.get("color_main_name", ""), data.get("color_main_hex", "")],
|
||||
["Акцент 1", data.get("color_accent1_name", ""), data.get("color_accent1_hex", "")],
|
||||
["Акцент 2", data.get("color_accent2_name", ""), data.get("color_accent2_hex", "")],
|
||||
["Нейтральная база", data.get("color_neutral", ""), ""],
|
||||
])
|
||||
|
||||
doc.add_paragraph("Типографика:")
|
||||
doc.add_paragraph(f"Заголовки: {data.get('typography_headings', '—')}", style="List Bullet")
|
||||
doc.add_paragraph(f"Текст: {data.get('typography_body', '—')}", style="List Bullet")
|
||||
doc.add_paragraph(f"Акценты: {data.get('typography_accents', '—')}", style="List Bullet")
|
||||
|
||||
doc.add_heading("Б. Tone of Voice (Тон коммуникации)", level=3)
|
||||
doc.add_paragraph(data.get("tov_description", "—"))
|
||||
doc.add_paragraph(f"Референсы: {data.get('tov_references', '—')}")
|
||||
|
||||
tov_axes = _split_lines(data.get("tov_axes", ""))
|
||||
if tov_axes:
|
||||
doc.add_paragraph("Оси ToV:")
|
||||
for axis in tov_axes:
|
||||
doc.add_paragraph(axis, style="List Number")
|
||||
|
||||
# === 5. КОНТЕНТ-СТРАТЕГИЯ ===
|
||||
doc.add_heading("5. Контент-стратегия: Три столпа", level=1)
|
||||
doc.add_paragraph(
|
||||
"Контент строится на балансе трех направлений, работающих на разные этапы воронки доверия."
|
||||
)
|
||||
|
||||
pillars = [
|
||||
("1", data.get("pillar1_name", "Кто я"), data.get("pillar1_goal", ""),
|
||||
data.get("pillar1_formats", ""), data.get("pillar1_frequency", "30%")),
|
||||
("2", data.get("pillar2_name", "Решаю боль"), data.get("pillar2_goal", ""),
|
||||
data.get("pillar2_formats", ""), data.get("pillar2_frequency", "50%")),
|
||||
("3", data.get("pillar3_name", "Втягиваю"), data.get("pillar3_goal", ""),
|
||||
data.get("pillar3_formats", ""), data.get("pillar3_frequency", "20%")),
|
||||
]
|
||||
|
||||
_add_table(doc, ["Столп", "Цель", "Ключевые форматы", "Частота"], [
|
||||
[f"{p[1]}", p[2], p[3], p[4]]
|
||||
for p in pillars
|
||||
])
|
||||
|
||||
special = data.get("special_formats", "")
|
||||
if special:
|
||||
doc.add_paragraph(f"Особые форматы: {special}")
|
||||
|
||||
# === 6. ВОРОНКА И ПРОДУКТЫ ===
|
||||
doc.add_heading("6. Воронка вовлечения и продуктовый ряд", level=1)
|
||||
|
||||
doc.add_heading("Воронка (путь от незнакомца к клиенту):", level=3)
|
||||
funnel_stages = data.get("funnel", [])
|
||||
if funnel_stages:
|
||||
_add_table(doc, ["Этап", "Контент", "CTA"], [
|
||||
[s.get("name", ""), s.get("content", ""), s.get("cta", "")]
|
||||
for s in funnel_stages
|
||||
])
|
||||
else:
|
||||
doc.add_paragraph("—")
|
||||
|
||||
doc.add_heading("Продуктовый ряд (от простого к сложному):", level=3)
|
||||
doc.add_paragraph(f"Входная точка (Lead Magnet): {data.get('product_lead_magnet', '—')}")
|
||||
doc.add_paragraph(f"Самая простая услуга: {data.get('product_simple', '—')}")
|
||||
doc.add_paragraph(f"Услуга-конвертер: {data.get('product_converter', '—')}")
|
||||
doc.add_paragraph(f"Комплексные решения: {data.get('product_complex', '—')}")
|
||||
doc.add_paragraph(f"Экспертные продукты: {data.get('product_expert', '—')}")
|
||||
|
||||
# === 7. МЕТРИКИ ===
|
||||
doc.add_heading("7. Метрики", level=1)
|
||||
|
||||
quant_rows = _split_pipe(data.get("metrics_quantitative", ""))
|
||||
if quant_rows:
|
||||
doc.add_heading("Количественные:", level=3)
|
||||
_add_table(doc, ["Метрика", "Целевое значение", "Измерение"], quant_rows)
|
||||
|
||||
qual_rows = _split_pipe(data.get("metrics_qualitative", ""))
|
||||
if qual_rows:
|
||||
doc.add_heading("Качественные:", level=3)
|
||||
_add_table(doc, ["Метрика", "Целевое значение", "Измерение"], qual_rows)
|
||||
|
||||
comm_rows = _split_pipe(data.get("metrics_commercial", ""))
|
||||
if comm_rows:
|
||||
doc.add_heading("Коммерческие:", level=3)
|
||||
_add_table(doc, ["Метрика", "Целевое значение", "Измерение"], comm_rows)
|
||||
|
||||
# === 8. РИСКИ И РЕКОМЕНДАЦИИ ===
|
||||
doc.add_heading("8. Потенциальные риски и рекомендации", level=1)
|
||||
|
||||
risk_rows = _split_pipe(data.get("risks", ""))
|
||||
if risk_rows:
|
||||
doc.add_heading("Потенциальные риски:", level=3)
|
||||
_add_table(doc, ["Риск", "Решение"], risk_rows)
|
||||
|
||||
recs = _split_lines(data.get("recommendations", ""))
|
||||
if recs:
|
||||
doc.add_heading("Рекомендации:", level=3)
|
||||
for rec in recs:
|
||||
doc.add_paragraph(rec, style="List Bullet")
|
||||
|
||||
# === 9. АРХЕТИПИЧЕСКИЙ ОБРАЗ ===
|
||||
doc.add_heading("9. Архетипический образ", level=1)
|
||||
|
||||
doc.add_heading(f"Ключевой архетип: {data.get('archetype_key_name', '—')}", level=3)
|
||||
doc.add_paragraph(f"Суть: {data.get('archetype_key_essence', '—')}")
|
||||
manifestations = _split_lines(data.get("archetype_key_manifestations", ""))
|
||||
if manifestations:
|
||||
doc.add_paragraph("Проявления:")
|
||||
for m in manifestations:
|
||||
doc.add_paragraph(m, style="List Bullet")
|
||||
|
||||
supporting = _split_pipe(data.get("archetype_supporting", ""))
|
||||
if supporting:
|
||||
doc.add_heading("Вспомогательные архетипы:", level=3)
|
||||
_add_table(doc, ["Архетип", "Суть", "Проявления"], supporting)
|
||||
|
||||
doc.add_heading("Синтез архетипов:", level=3)
|
||||
doc.add_paragraph(data.get("archetype_synthesis", "—"))
|
||||
|
||||
table_rows = _split_pipe(data.get("archetype_table", ""))
|
||||
if table_rows:
|
||||
doc.add_heading("Таблица проявлений:", level=3)
|
||||
_add_table(doc, ["Ситуация / Контент-формат", "Доминирующий архетип", "Как это выглядит и звучит"], table_rows)
|
||||
|
||||
# === 10. ПРОМПТ ДЛЯ ГЕНЕРАЦИИ КОНТЕНТА ===
|
||||
doc.add_heading("10. Промпт для генерации контента", level=1)
|
||||
|
||||
doc.add_heading("Контекст и личность:", level=3)
|
||||
doc.add_paragraph(data.get("prompt_context", "—"))
|
||||
|
||||
doc.add_heading("Tone of Voice:", level=3)
|
||||
doc.add_paragraph(data.get("prompt_tov", "—"))
|
||||
|
||||
doc.add_heading("Структура мысли:", level=3)
|
||||
doc.add_paragraph(data.get("prompt_structure", "—"))
|
||||
|
||||
doc.add_heading("Контент-направления:", level=3)
|
||||
for line in _split_lines(data.get("prompt_directions", "")):
|
||||
doc.add_paragraph(line, style="List Bullet")
|
||||
|
||||
doc.add_heading("Форматы и шаблоны:", level=3)
|
||||
doc.add_paragraph(data.get("prompt_formats", "—"))
|
||||
|
||||
doc.add_heading("Технические требования к тексту:", level=3)
|
||||
doc.add_paragraph(data.get("prompt_technical", "—"))
|
||||
|
||||
# === Сериализация в байты ===
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
@@ -120,43 +120,162 @@ a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
/* === Sidebar === */
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 220px;
|
||||
height: 100vh;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
transition: transform 0.25s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.topbar .brand {
|
||||
font-weight: 600;
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 18px 16px;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar nav {
|
||||
.sidebar-brand:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-brand-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
.sidebar-link svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-soft-border);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 6px 12px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-form {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
z-index: 200;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 220px;
|
||||
max-width: 720px;
|
||||
padding: 28px 24px 60px;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
padding: 60px 16px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
@@ -164,25 +283,6 @@ a:hover {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 16px;
|
||||
@@ -553,3 +653,557 @@ h2 {
|
||||
color: var(--text-faint);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* === Опрос медиа-образа === */
|
||||
|
||||
.survey-wizard {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.survey-progress {
|
||||
height: 6px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.survey-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.survey-steps-indicator {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.8rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.step-dot.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.step-dot.done {
|
||||
background: var(--success-bg);
|
||||
border-color: var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.survey-step h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.survey-step h3 {
|
||||
font-size: 1rem;
|
||||
margin-top: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.survey-step .form {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.funnel-stage {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.funnel-stage label {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.survey-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.survey-nav #stepCounter {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.survey-nav button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.survey-nav button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.survey-nav button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#channelsTable input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* === Изображения в списке постов === */
|
||||
|
||||
.post-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.post-thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* === Loader / Background Task === */
|
||||
|
||||
.task-loader {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 4px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.task-loader-text {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-loader-sub {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.task-error {
|
||||
display: none;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-result {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* === Image Templates === */
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 14px 10px 12px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.1s ease;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.template-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.template-card.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft-border);
|
||||
}
|
||||
|
||||
.template-icon {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.template-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-faint);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.template-card-custom {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.selected-hint {
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-soft-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--accent);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* === Concept Cards === */
|
||||
|
||||
.concept-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.concept-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.concept-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.concept-title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.concept-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.concept-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.concept-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
background: var(--badge-bg);
|
||||
color: var(--badge-text);
|
||||
}
|
||||
|
||||
/* === Provider Tabs === */
|
||||
.provider-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.provider-tab {
|
||||
padding: 10px 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.provider-tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.provider-tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.provider-tab-content {
|
||||
display: none;
|
||||
}
|
||||
.provider-tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* === Inline Post Editor === */
|
||||
|
||||
.post-editor {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
padding: 14px 16px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.post-editor:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||
}
|
||||
.post-editor-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-success {
|
||||
background: var(--success-bg, #d4edda);
|
||||
color: var(--success-text, #155724);
|
||||
border: 1px solid var(--success-border, #c3e6cb);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* === Revision Chat === */
|
||||
|
||||
.revision-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.revision-msg {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.15s;
|
||||
}
|
||||
.revision-msg:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
.revision-msg--current {
|
||||
border-color: var(--accent);
|
||||
border-width: 2px;
|
||||
}
|
||||
.revision-msg-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.revision-msg-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
.revision-msg-date {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.revision-msg-preview {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.revision-msg-full {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.revision-msg-content {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* === AI Chat === */
|
||||
|
||||
.ai-chat {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.ai-chat-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ai-chat-msg--user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.ai-chat-msg--ai {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.ai-chat-msg--system {
|
||||
align-items: center;
|
||||
}
|
||||
.ai-chat-bubble {
|
||||
max-width: 85%;
|
||||
padding: 10px 16px;
|
||||
border-radius: 14px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.ai-chat-bubble--user {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.ai-chat-bubble--ai {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.ai-chat-bubble--system {
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
.ai-chat-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-faint);
|
||||
margin-top: 3px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.ai-chat-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ai-chat-preview {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.ai-chat-full pre {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.ai-chat-textarea {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
resize: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.ai-chat-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.ai-chat-textarea:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -4,18 +4,17 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Личный кабинет{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css?v=2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=3">
|
||||
<script>
|
||||
(function () {
|
||||
function isDark() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
}
|
||||
function setIcon(btn) {
|
||||
if (btn) btn.textContent = isDark() ? '☀️' : '🌙';
|
||||
if (btn) btn.textContent = isDark() ? '\u2600\uFE0F' : '\uD83C\uDF19';
|
||||
}
|
||||
try {
|
||||
var saved = localStorage.getItem('theme');
|
||||
// Тёмная тема по умолчанию: dark включается всегда, кроме явного выбора 'light'
|
||||
if (saved !== 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
@@ -39,30 +38,97 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="{{ '/dashboard' if user else '/login' }}">🗂 Личный кабинет</a>
|
||||
<nav>
|
||||
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Переключить тему">🌙</button>
|
||||
{% if user %}
|
||||
<a href="/dashboard">Главная</a>
|
||||
<a href="/generate">Пост</a>
|
||||
<a href="/plan">Контент-план</a>
|
||||
<a href="/history">История</a>
|
||||
<a href="/images">Картинки</a>
|
||||
<a href="/stats">Статистика</a>
|
||||
<a href="/profile/edit">Профиль</a>
|
||||
<form action="/logout" method="post" class="inline-form">
|
||||
<button type="submit" class="link-button">Выйти</button>
|
||||
</form>
|
||||
</nav>
|
||||
{% else %}
|
||||
<a href="/login">Вход</a>
|
||||
<a href="/register">Регистрация</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</header>
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<a class="sidebar-brand" href="{{ '/dashboard' if user else '/login' }}">
|
||||
<svg class="sidebar-brand-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
<span>ИИ Пресс-секретарь</span>
|
||||
</a>
|
||||
|
||||
<main class="container">
|
||||
{% if user %}
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/dashboard" class="sidebar-link {% if request.path == '/dashboard' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
<span>Главная</span>
|
||||
</a>
|
||||
<a href="/generate" class="sidebar-link {% if request.path == '/generate' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
<span>Создать пост</span>
|
||||
</a>
|
||||
<a href="/plan" class="sidebar-link {% if request.path == '/plan' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>План</span>
|
||||
</a>
|
||||
<a href="/history" class="sidebar-link {% if request.path == '/history' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
<span>Мои посты</span>
|
||||
</a>
|
||||
<a href="/images" class="sidebar-link {% if request.path == '/images' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
<span>Картинки</span>
|
||||
</a>
|
||||
<a href="/channels" class="sidebar-link {% if request.path == '/channels' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"/></svg>
|
||||
<span>Каналы</span>
|
||||
</a>
|
||||
<a href="/surveys" class="sidebar-link {% if '/surveys' in request.path %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/><path d="M9 14l2 2 4-4"/></svg>
|
||||
<span>Медиа-образы</span>
|
||||
</a>
|
||||
<a href="/stats" class="sidebar-link {% if request.path == '/stats' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
<span>Статистика</span>
|
||||
</a>
|
||||
|
||||
<div class="sidebar-divider"></div>
|
||||
|
||||
<a href="/provider" class="sidebar-link {% if request.path == '/provider' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>
|
||||
<span>ИИ</span>
|
||||
</a>
|
||||
<a href="/profile/edit" class="sidebar-link {% if request.path == '/profile/edit' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<span>Профиль</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button type="button" class="sidebar-link" data-theme-toggle aria-label="Переключить тему">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<span>Тема</span>
|
||||
</button>
|
||||
<form action="/logout" method="post" class="sidebar-form">
|
||||
<button type="submit" class="sidebar-link">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/login" class="sidebar-link {% if request.path == '/login' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||
<span>Вход</span>
|
||||
</a>
|
||||
<a href="/register" class="sidebar-link {% if request.path == '/register' %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
|
||||
<span>Регистрация</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button type="button" class="sidebar-link" data-theme-toggle aria-label="Переключить тему">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<span>Тема</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
<button class="sidebar-toggle" id="sidebarToggle" aria-label="Меню">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
|
||||
<main class="main-content">
|
||||
{% if flash_message %}
|
||||
<div class="flash">{{ flash_message }}</div>
|
||||
{% endif %}
|
||||
@@ -77,12 +143,27 @@
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var toggle = document.getElementById('sidebarToggle');
|
||||
var sidebar = document.getElementById('sidebar');
|
||||
if (toggle && sidebar) {
|
||||
toggle.addEventListener('click', function () {
|
||||
sidebar.classList.toggle('open');
|
||||
});
|
||||
document.addEventListener('click', function (e) {
|
||||
if (sidebar.classList.contains('open') && !sidebar.contains(e.target) && e.target !== toggle) {
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
function copyText(text, btn) {
|
||||
function done(ok) {
|
||||
if (!btn) return;
|
||||
var old = btn.textContent;
|
||||
btn.textContent = ok ? '✅ Скопировано' : '❌ Ошибка';
|
||||
btn.textContent = ok ? '\u2705 \u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u043e' : '\u274c \u041e\u0448\u0438\u0431\u043a\u0430';
|
||||
setTimeout(function () { btn.textContent = old; }, 1500);
|
||||
}
|
||||
function fallback() {
|
||||
@@ -109,6 +190,72 @@
|
||||
var el = document.getElementById(btn.getAttribute('data-copy-target'));
|
||||
if (el) copyText(el.textContent, btn);
|
||||
});
|
||||
|
||||
/* === Background Task Engine === */
|
||||
window.TaskEngine = {
|
||||
start: function(formEl, opts) {
|
||||
var fd = new FormData(formEl);
|
||||
var loaderEl = document.getElementById(opts.loaderId || 'taskLoader');
|
||||
var resultEl = document.getElementById(opts.resultId || 'taskResult');
|
||||
var formContainer = document.getElementById(opts.formId || 'taskForm');
|
||||
var btn = formEl.querySelector('button[type="submit"]');
|
||||
var errEl = document.getElementById(opts.errorId || 'taskError');
|
||||
|
||||
// Hide form, show loader
|
||||
if (formContainer) formContainer.style.display = 'none';
|
||||
if (resultEl) resultEl.style.display = 'none';
|
||||
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
||||
if (loaderEl) loaderEl.style.display = 'flex';
|
||||
|
||||
fetch('/api/tasks', { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'error' || data.error) {
|
||||
if (loaderEl) loaderEl.style.display = 'none';
|
||||
if (formContainer) formContainer.style.display = '';
|
||||
if (errEl) { errEl.style.display = 'block'; errEl.textContent = data.error || 'Ошибка запуска задачи'; }
|
||||
return;
|
||||
}
|
||||
TaskEngine.poll(data.task_id, opts);
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (loaderEl) loaderEl.style.display = 'none';
|
||||
if (formContainer) formContainer.style.display = '';
|
||||
if (errEl) { errEl.style.display = 'block'; errEl.textContent = 'Ошибка сети: ' + err.message; }
|
||||
});
|
||||
},
|
||||
poll: function(taskId, opts) {
|
||||
var loaderEl = document.getElementById(opts.loaderId || 'taskLoader');
|
||||
var resultEl = document.getElementById(opts.resultId || 'taskResult');
|
||||
var formContainer = document.getElementById(opts.formId || 'taskForm');
|
||||
var errEl = document.getElementById(opts.errorId || 'taskError');
|
||||
|
||||
fetch('/api/tasks/' + taskId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'running' || data.status === 'pending') {
|
||||
setTimeout(function() { TaskEngine.poll(taskId, opts); }, 1500);
|
||||
return;
|
||||
}
|
||||
if (loaderEl) loaderEl.style.display = 'none';
|
||||
|
||||
if (data.status === 'error') {
|
||||
if (formContainer) formContainer.style.display = '';
|
||||
if (errEl) { errEl.style.display = 'block'; errEl.textContent = data.error || 'Ошибка генерации'; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'done' && opts.onDone) {
|
||||
opts.onDone(data.result || {});
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
if (loaderEl) loaderEl.style.display = 'none';
|
||||
if (formContainer) formContainer.style.display = '';
|
||||
if (errEl) { errEl.style.display = 'block'; errEl.textContent = 'Ошибка сети: ' + err.message; }
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Добавить канал — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Добавить канал</h1>
|
||||
<div class="card">
|
||||
<p class="form-hint">
|
||||
Введите <strong>@username</strong> канала/чата или числовой ID.<br>
|
||||
Бот должен быть добавлен в админы канала, иначе публикация не сработает.
|
||||
</p>
|
||||
</div>
|
||||
<form method="post" action="/channels/add" class="form">
|
||||
<label>@username или ID
|
||||
<input type="text" name="chat_input" value="{{ form.chat_input if form else '' }}"
|
||||
placeholder="@my_channel или -1001234567890" required>
|
||||
</label>
|
||||
<button type="submit">Проверить и добавить</button>
|
||||
</form>
|
||||
<p><a href="/channels">← Назад к каналам</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Каналы публикации — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Каналы публикации</h1>
|
||||
<p class="form-hint">Добавьте Telegram-каналы и чаты, куда бот будет публиковать посты. Бот должен быть админом в канале.</p>
|
||||
|
||||
<div class="grid">
|
||||
<a class="tile" href="/channels/add">➕ Добавить канал</a>
|
||||
</div>
|
||||
|
||||
{% if channels %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Название</th><th>Тип</th><th>Username</th><th>Добавлен</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ch in channels %}
|
||||
<tr>
|
||||
<td>{{ ch.title }}</td>
|
||||
<td>{{ ch.chat_type }}</td>
|
||||
<td>{% if ch.username %}@{{ ch.username }}{% else %}—{% endif %}</td>
|
||||
<td>{{ ch.created_at }}</td>
|
||||
<td>
|
||||
<form action="/channels/{{ ch.id }}/delete" method="post" class="inline-form" onsubmit="return confirm('Удалить канал?')">
|
||||
<button type="submit" class="button-link button-link-danger">🗑</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p>Нет добавленных каналов. <a href="/channels/add">Добавьте первый →</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -4,15 +4,36 @@
|
||||
<h1>Привет, {{ user.name }}!</h1>
|
||||
<div class="card">
|
||||
<p><strong>Телефон:</strong> {{ user.phone }}</p>
|
||||
<p><strong>Вид бизнеса:</strong> {{ business_name }}</p>
|
||||
<p><strong>Описание:</strong> {{ user.description }}</p>
|
||||
<p><strong>Email:</strong> {{ user.email }}</p>
|
||||
<p><strong>В кабинете с:</strong> {{ user.created_at }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Медиа-образ</h2>
|
||||
{% if latest_survey %}
|
||||
<p><strong>Активный:</strong> <a href="/surveys/{{ latest_survey.id }}">{{ latest_survey.title }}</a></p>
|
||||
<p class="form-hint">Посты генерируются на основе этого медиа-образа.</p>
|
||||
<div class="post-actions">
|
||||
<a href="/surveys/{{ latest_survey.id }}" class="copy-btn">Открыть</a>
|
||||
<a href="/survey" class="copy-btn">➕ Создать новый</a>
|
||||
<a href="/survey/import" class="copy-btn">📤 Загрузить из файла</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Медиа-образ ещё не создан. Создайте его — посты будут генерироваться на его основе.</p>
|
||||
<div class="post-actions">
|
||||
<a href="/survey" class="copy-btn">➕ Создать медиа-образ</a>
|
||||
<a href="/survey/import" class="copy-btn">📤 Загрузить из файла</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<a class="tile" href="/generate">💡 Сгенерировать пост</a>
|
||||
<a class="tile" href="/plan">📅 Контент-план</a>
|
||||
<a class="tile" href="/history">📜 История постов</a>
|
||||
<a class="tile" href="/history">📜 Мои посты</a>
|
||||
<a class="tile" href="/images">🖼 Медиа-библиотека</a>
|
||||
<a class="tile" href="/channels">📢 Каналы публикации</a>
|
||||
<a class="tile" href="/surveys">📋 Мои медиа-образы</a>
|
||||
<a class="tile" href="/stats">📈 Статистика</a>
|
||||
<a class="tile" href="/profile/edit">✏️ Редактировать профиль</a>
|
||||
<a class="tile tile-danger" href="/delete-account">🗑 Удалить аккаунт</a>
|
||||
|
||||
@@ -2,25 +2,99 @@
|
||||
{% block title %}Сгенерировать пост — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Пост в своём стиле</h1>
|
||||
<form method="post" action="/generate" class="form">
|
||||
<label>Платформа
|
||||
<select name="platform" required>
|
||||
{% for key, label in platforms %}
|
||||
<option value="{{ key }}" {% if form and form.platform == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Стиль (например: дружелюбный, с юмором, экспертный)
|
||||
<input type="text" name="style" value="{{ form.style if form else '' }}" required>
|
||||
</label>
|
||||
<label>Пожелания к посту
|
||||
<textarea name="wishes" rows="3">{{ form.wishes if form else '' }}</textarea>
|
||||
</label>
|
||||
<button type="submit">Сгенерировать</button>
|
||||
</form>
|
||||
|
||||
{% if latest_survey %}
|
||||
<div class="card">
|
||||
<p><strong>Медиа-образ:</strong> <a href="/surveys/{{ latest_survey.id }}">{{ latest_survey.title }}</a></p>
|
||||
<p class="form-hint">Пост сгенерируется на основе этого медиа-образа (личность, УТП, Tone of Voice, структура мысли).</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p>⚠️ Медиа-образ не найден. Посты генерируются на основе медиа-образа.</p>
|
||||
<a href="/survey" class="copy-btn">➕ Создать медиа-образ</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="taskError" class="task-error"></div>
|
||||
|
||||
<div id="taskLoader" class="task-loader">
|
||||
<div class="spinner"></div>
|
||||
<div class="task-loader-text">Генерирую пост...</div>
|
||||
<div class="task-loader-sub">Это может занять 15–60 секунд</div>
|
||||
</div>
|
||||
|
||||
<div id="taskResult" class="task-result"></div>
|
||||
|
||||
<div id="taskForm">
|
||||
<form method="post" action="/generate" class="form" id="generateForm" onsubmit="return startPostGen(event)">
|
||||
{% if surveys and surveys|length > 1 %}
|
||||
<label>Медиа-образ
|
||||
<select name="survey_id">
|
||||
<option value="">Последний (по умолчанию)</option>
|
||||
{% for s in surveys %}
|
||||
<option value="{{ s.id }}">{{ s.title }} ({{ s.created_at }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
<label>Платформа
|
||||
<select name="platform" required>
|
||||
{% for key, label in platforms %}
|
||||
<option value="{{ key }}" {% if (prefill and prefill.platform == key) or (form and form.platform == key) %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Стиль (например: дружелюбный, с юмором, экспертный)
|
||||
<input type="text" name="style" value="{{ form.style if form else '' }}" required>
|
||||
</label>
|
||||
<label>Пожелания к посту
|
||||
<textarea name="wishes" rows="3">{{ prefill.idea if prefill else (form.wishes if form else '') }}</textarea>
|
||||
</label>
|
||||
<input type="hidden" name="task_type" value="generate_post">
|
||||
{% if prefill %}<input type="hidden" name="plan_id" value="{{ prefill.plan_id }}">{% endif %}
|
||||
<button type="submit" {% if not latest_survey %}disabled{% endif %}>Сгенерировать</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if result %}
|
||||
<h2>Готовый пост</h2>
|
||||
<div class="card result">{{ result }}</div>
|
||||
<div class="post-actions">
|
||||
<button type="button" class="copy-btn" data-copy-target="post-text">📋 Копировать</button>
|
||||
</div>
|
||||
<div id="post-text" hidden>{{ result }}</div>
|
||||
{% endif %}
|
||||
<p><a href="/dashboard">← Назад в кабинет</a></p>
|
||||
|
||||
<script>
|
||||
function startPostGen(e) {
|
||||
e.preventDefault();
|
||||
var form = document.getElementById('generateForm');
|
||||
TaskEngine.start(form, {
|
||||
loaderId: 'taskLoader',
|
||||
formId: 'taskForm',
|
||||
errorId: 'taskError',
|
||||
resultId: 'taskResult',
|
||||
onDone: function(result) {
|
||||
var html = '<h2>Готовый пост</h2>' +
|
||||
'<div class="card result">' + escHtml(result.content) + '</div>' +
|
||||
'<div class="post-actions">' +
|
||||
'<button type="button" class="copy-btn" onclick="copyText(\'' + escAttr(result.content).replace(/'/g, "\\'") + '\', this)">📋 Копировать</button>' +
|
||||
' <a href="/posts/' + result.post_id + '" class="copy-btn">Открыть пост →</a>' +
|
||||
'</div>';
|
||||
document.getElementById('taskResult').innerHTML = html;
|
||||
document.getElementById('taskResult').style.display = 'block';
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
function escHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
function escAttr(s) {
|
||||
return (s || '').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"').replace(/\n/g,'\\n');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,13 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}История постов — Личный кабинет{% endblock %}
|
||||
{% block title %}Мои посты — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>История постов</h1>
|
||||
<h1>Мои посты</h1>
|
||||
{% if posts %}
|
||||
<ul class="post-list">
|
||||
{% for post in posts %}
|
||||
<li class="card">
|
||||
<div class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}</div>
|
||||
<div class="post-meta">{{ platform_label(post.platform) }} · {{ post.created_at }}
|
||||
{% if post.published %} · ✅ Опубликован{% endif %}
|
||||
</div>
|
||||
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
|
||||
{% if post.images %}
|
||||
<div class="post-images">
|
||||
{% for img in post.images %}
|
||||
<img src="/media/{{ img.filename }}" class="post-thumb" alt="">
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="post-actions">
|
||||
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
|
||||
<a class="button-link" href="/posts/{{ post.id }}">👁 Открыть</a>
|
||||
|
||||
@@ -2,26 +2,173 @@
|
||||
{% block title %}Сгенерировать картинку — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Генерация картинки</h1>
|
||||
<form method="post" action="/images/generate" class="form">
|
||||
<label>Что нарисовать?
|
||||
<textarea name="prompt" rows="4" required placeholder="Например: современный кофейный стакан на белом фоне, логотип для кофейни">{{ form.prompt if form else '' }}</textarea>
|
||||
</label>
|
||||
<label>Размер
|
||||
<select name="size" required>
|
||||
{% for key, (value, label) in sizes.items() %}
|
||||
<option value="{{ key }}" {% if form and form.size == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Стиль
|
||||
<select name="style">
|
||||
{% for key, (label, suffix) in styles.items() %}
|
||||
<option value="{{ key }}" {% if form and form.style == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">🎨 Сгенерировать</button>
|
||||
<p class="form-hint">Генерация занимает 10–60 секунд. Картинка сохранится в медиа-библиотеку.</p>
|
||||
</form>
|
||||
|
||||
<div id="taskError" class="task-error"></div>
|
||||
|
||||
<div id="taskLoader" class="task-loader">
|
||||
<div class="spinner"></div>
|
||||
<div class="task-loader-text">Генерирую картинку...</div>
|
||||
<div class="task-loader-sub">Это может занять 15–60 секунд</div>
|
||||
</div>
|
||||
|
||||
<div id="taskResult" class="task-result"></div>
|
||||
|
||||
<div id="taskForm">
|
||||
<!-- Выбор шаблона -->
|
||||
<h2>Выбери шаблон</h2>
|
||||
<div class="template-grid" id="templateGrid">
|
||||
{% for t in templates %}
|
||||
<button type="button" class="template-card" data-key="{{ t.key }}"
|
||||
data-prompt="{{ t.prompt }}" data-style="{{ t.style }}"
|
||||
data-size="{{ t.size }}" data-hint="{{ t.hint }}">
|
||||
<span class="template-icon">{{ t.icon }}</span>
|
||||
<span class="template-name">{{ t.name }}</span>
|
||||
<span class="template-hint">{{ t.hint }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
<button type="button" class="template-card template-card-custom" data-key="custom">
|
||||
<span class="template-icon">✏️</span>
|
||||
<span class="template-name">Свой вариант</span>
|
||||
<span class="template-hint">Напиши промпт сам</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="selectedTemplateHint" class="selected-hint" style="display:none;"></div>
|
||||
|
||||
<!-- Форма генерации -->
|
||||
<form method="post" action="/images/generate" class="form" id="imageForm" onsubmit="return startImageGen(event)" enctype="multipart/form-data">
|
||||
<label>Что нарисовать?
|
||||
<textarea name="prompt" id="promptInput" rows="4" required
|
||||
placeholder="Например: современный кофейный стакан на белом фоне, логотип для кофейни">{{ form.prompt if form else '' }}</textarea>
|
||||
</label>
|
||||
<label>Исходное изображение <span class="form-hint">(необязательно) — ИИ использует его как основу</span>
|
||||
<input type="file" name="reference_image" id="referenceImage" accept="image/*">
|
||||
</label>
|
||||
<div id="refImagePreview" style="display:none; margin: 5px 0 10px;">
|
||||
<img id="refImageThumb" style="max-height:120px; border-radius:8px; border:1px solid var(--border);">
|
||||
<button type="button" class="button-link button-link-danger" onclick="clearRefImage()" style="margin-left:8px">✕ Убрать</button>
|
||||
</div>
|
||||
<label>Размер
|
||||
<select name="size" id="sizeInput" required>
|
||||
{% for key, (value, label) in sizes.items() %}
|
||||
<option value="{{ key }}" {% if form and form.size == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Стиль
|
||||
<select name="style" id="styleInput">
|
||||
{% for key, (label, suffix) in styles.items() %}
|
||||
<option value="{{ key }}" {% if form and form.style == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="task_type" value="generate_image">
|
||||
<button type="submit" id="submitBtn" disabled>🎨 Сгенерировать</button>
|
||||
<p class="form-hint">Генерация занимает 10–60 секунд. Картинка сохранится в медиа-библиотеку.</p>
|
||||
</form>
|
||||
</div>
|
||||
<p><a href="/images">← Назад в библиотеку</a></p>
|
||||
|
||||
<script>
|
||||
function clearRefImage() {
|
||||
document.getElementById('referenceImage').value = '';
|
||||
document.getElementById('refImagePreview').style.display = 'none';
|
||||
}
|
||||
document.getElementById('referenceImage').addEventListener('change', function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(ev) {
|
||||
document.getElementById('refImageThumb').src = ev.target.result;
|
||||
document.getElementById('refImagePreview').style.display = '';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
var grid = document.getElementById('templateGrid');
|
||||
var promptInput = document.getElementById('promptInput');
|
||||
var sizeInput = document.getElementById('sizeInput');
|
||||
var styleInput = document.getElementById('styleInput');
|
||||
var hintEl = document.getElementById('selectedTemplateHint');
|
||||
var submitBtn = document.getElementById('submitBtn');
|
||||
var selectedKey = null;
|
||||
|
||||
grid.addEventListener('click', function(e) {
|
||||
var card = e.target.closest('.template-card');
|
||||
if (!card) return;
|
||||
|
||||
// Снять выделение со всех
|
||||
grid.querySelectorAll('.template-card').forEach(function(c) { c.classList.remove('selected'); });
|
||||
card.classList.add('selected');
|
||||
selectedKey = card.dataset.key;
|
||||
|
||||
if (selectedKey === 'custom') {
|
||||
hintEl.style.display = 'none';
|
||||
promptInput.value = '';
|
||||
promptInput.focus();
|
||||
promptInput.removeAttribute('readonly');
|
||||
submitBtn.disabled = !promptInput.value.trim();
|
||||
return;
|
||||
}
|
||||
|
||||
var prompt = card.dataset.prompt;
|
||||
var style = card.dataset.style;
|
||||
var size = card.dataset.size;
|
||||
var hint = card.dataset.hint;
|
||||
|
||||
promptInput.value = prompt;
|
||||
if (styleInput.querySelector('option[value="' + style + '"]')) {
|
||||
styleInput.value = style;
|
||||
}
|
||||
if (sizeInput.querySelector('option[value="' + size + '"]')) {
|
||||
sizeInput.value = size;
|
||||
}
|
||||
|
||||
hintEl.textContent = '💡 ' + hint;
|
||||
hintEl.style.display = 'block';
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Разрешить ввод вручную при изменении текста
|
||||
promptInput.addEventListener('input', function() {
|
||||
if (selectedKey && selectedKey !== 'custom') {
|
||||
// Пользователь изменил текст — переключаем на "свой вариант"
|
||||
grid.querySelectorAll('.template-card').forEach(function(c) { c.classList.remove('selected'); });
|
||||
var customCard = grid.querySelector('[data-key="custom"]');
|
||||
if (customCard) customCard.classList.add('selected');
|
||||
selectedKey = 'custom';
|
||||
hintEl.style.display = 'none';
|
||||
}
|
||||
submitBtn.disabled = !promptInput.value.trim();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function startImageGen(e) {
|
||||
e.preventDefault();
|
||||
var form = document.getElementById('imageForm');
|
||||
TaskEngine.start(form, {
|
||||
loaderId: 'taskLoader',
|
||||
formId: 'taskForm',
|
||||
errorId: 'taskError',
|
||||
resultId: 'taskResult',
|
||||
onDone: function(result) {
|
||||
var html = '<div class="card">' +
|
||||
'<p>✅ Картинка сгенерирована!</p>' +
|
||||
'<div class="media-grid"><div class="media-card">' +
|
||||
'<a href="/media/' + result.filename + '" class="media-link" target="_blank">' +
|
||||
'<img class="media-thumb" src="/media/' + result.filename + '" alt="Картинка">' +
|
||||
'</a></div></div>' +
|
||||
'<p><a href="/images" class="copy-btn">Перейти в библиотеку →</a> ' +
|
||||
'<a href="/images/generate" class="copy-btn">Ещё одна →</a></p></div>';
|
||||
document.getElementById('taskResult').innerHTML = html;
|
||||
document.getElementById('taskResult').style.display = 'block';
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -24,6 +24,7 @@
|
||||
</div>
|
||||
<div class="plan-idea">{{ item.idea }}</div>
|
||||
<div class="post-actions">
|
||||
<a href="/generate?plan_id={{ item.id }}&platform={{ item.platform }}&idea={{ item.idea | urlencode }}" class="copy-btn">✍️ Создать пост</a>
|
||||
<form method="post" action="/plan/{{ item.id }}/status" class="inline-form">
|
||||
<select name="status" class="status-select">
|
||||
<option value="planned" {% if item.status == 'planned' %}selected{% endif %}>📝 В плане</option>
|
||||
@@ -47,12 +48,45 @@
|
||||
{% endif %}
|
||||
|
||||
<h2>{% if plan %}Пересоставить план{% else %}Составить план{% endif %}</h2>
|
||||
<form method="post" action="/plan" class="form"
|
||||
onsubmit="{% if plan %}return confirm('Новый план заменит текущий (отметки статусов сбросятся). Продолжить?');{% endif %}">
|
||||
<label>На сколько дней (1-14)
|
||||
<input type="number" name="days" min="1" max="14" value="7" required>
|
||||
</label>
|
||||
<button type="submit">📅 Составить план</button>
|
||||
</form>
|
||||
|
||||
<div id="taskError" class="task-error"></div>
|
||||
|
||||
<div id="taskLoader" class="task-loader">
|
||||
<div class="spinner"></div>
|
||||
<div class="task-loader-text">Составляю контент-план...</div>
|
||||
<div class="task-loader-sub">Генерация занимает 20–60 секунд</div>
|
||||
</div>
|
||||
|
||||
<div id="taskResult" class="task-result"></div>
|
||||
|
||||
<div id="taskForm">
|
||||
<form method="post" action="/plan" class="form" id="planForm" onsubmit="return startPlanGen(event)">
|
||||
<label>На сколько дней (1-14)
|
||||
<input type="number" name="days" min="1" max="14" value="7" required>
|
||||
</label>
|
||||
<input type="hidden" name="task_type" value="generate_plan">
|
||||
<button type="submit">📅 Составить план</button>
|
||||
</form>
|
||||
</div>
|
||||
<p><a href="/dashboard">← Назад в кабинет</a></p>
|
||||
|
||||
<script>
|
||||
function startPlanGen(e) {
|
||||
e.preventDefault();
|
||||
var form = document.getElementById('planForm');
|
||||
TaskEngine.start(form, {
|
||||
loaderId: 'taskLoader',
|
||||
formId: 'taskForm',
|
||||
errorId: 'taskError',
|
||||
resultId: 'taskResult',
|
||||
onDone: function(result) {
|
||||
var html = '<div class="card"><p>✅ План на ' + result.days + ' дн. составлен! (' + result.items + ' пунктов)</p>' +
|
||||
'<p><a href="/plan" class="copy-btn">Обновить страницу →</a></p></div>';
|
||||
document.getElementById('taskResult').innerHTML = html;
|
||||
document.getElementById('taskResult').style.display = 'block';
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -7,18 +7,443 @@
|
||||
{{ platform_label(post.platform) }} · {{ post.created_at }}
|
||||
{% if post.style %} · Стиль: {{ post.style }}{% endif %}
|
||||
</div>
|
||||
<div class="post-content" id="post-content-{{ post.id }}">{{ post.content }}</div>
|
||||
{% if post.published %}
|
||||
<div class="post-meta" style="color: var(--success-text);">
|
||||
✅ Опубликован в «{{ post.published_to }}» · {{ post.published_at }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if post.wishes %}
|
||||
<div class="post-notes"><strong>Пожелания:</strong> {{ post.wishes }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- === ТЕКУЩИЙ ТЕКСТ ПОСТА === -->
|
||||
<div class="card" id="postCard">
|
||||
<h2>📄 Текст поста</h2>
|
||||
<div class="post-content" id="postContent">{{ post.content }}</div>
|
||||
<div class="post-editor-actions" style="margin-top: 12px;">
|
||||
<button type="button" class="copy-btn" onclick="copyPostText()">📋 Копировать</button>
|
||||
<a class="copy-btn" href="/posts/{{ post.id }}/publish">📤 Опубликовать</a>
|
||||
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Редактировать вручную</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === ЧАТ С НЕЙРОСЕТЬЮ === -->
|
||||
<div class="card">
|
||||
<h2>🤖 Редактировать с помощью ИИ</h2>
|
||||
<p class="form-hint">Напишите, что нужно изменить — нейросеть перепишет пост. Например: «сократи вдвое», «сделай официальнее», «добавь призыв к действию».</p>
|
||||
|
||||
<!-- История чата -->
|
||||
<div class="ai-chat" id="aiChat">
|
||||
<div class="ai-chat-msg ai-chat-msg--system">
|
||||
<div class="ai-chat-bubble ai-chat-bubble--system">Пост сгенерирован. Напишите инструкцию, чтобы изменить его.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Лоадер -->
|
||||
<div id="aiChatLoader" class="task-loader" style="display:none; padding: 15px;">
|
||||
<div class="spinner"></div>
|
||||
<div class="task-loader-text">Нейросеть переписывает пост...</div>
|
||||
</div>
|
||||
<div id="aiChatError" class="task-error"></div>
|
||||
|
||||
<!-- Поле ввода -->
|
||||
<div class="ai-chat-input">
|
||||
<textarea id="aiChatInput" class="ai-chat-textarea" rows="2"
|
||||
placeholder="Например: сократи вдвое, сделай веселее, добавь эмодзи..."
|
||||
onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault(); sendAiInstruction();}"></textarea>
|
||||
<button type="button" class="copy-btn" onclick="sendAiInstruction()" id="aiChatSendBtn">→ Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === СКРИПТ ЧАТА === -->
|
||||
<script>
|
||||
var _currentPostContent = {{ post.content | tojson }};
|
||||
|
||||
function copyPostText() {
|
||||
navigator.clipboard.writeText(document.getElementById('postContent').textContent);
|
||||
}
|
||||
|
||||
function sendAiInstruction() {
|
||||
var input = document.getElementById('aiChatInput');
|
||||
var instruction = input.value.trim();
|
||||
if (!instruction) return;
|
||||
|
||||
var errEl = document.getElementById('aiChatError');
|
||||
var loader = document.getElementById('aiChatLoader');
|
||||
var sendBtn = document.getElementById('aiChatSendBtn');
|
||||
|
||||
errEl.style.display = 'none';
|
||||
loader.style.display = 'flex';
|
||||
sendBtn.disabled = true;
|
||||
input.disabled = true;
|
||||
|
||||
// Добавляем сообщение пользователя в чат
|
||||
addChatMsg('user', instruction);
|
||||
input.value = '';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('task_type', 'modify_post');
|
||||
fd.append('post_id', '{{ post.id }}');
|
||||
fd.append('instruction', instruction);
|
||||
|
||||
fetch('/api/tasks', { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) { showAiError(errEl, data.error, loader, sendBtn, input); return; }
|
||||
pollModifyTask(data.task_id, errEl, loader, sendBtn, input, instruction);
|
||||
})
|
||||
.catch(function(e) { showAiError(errEl, 'Ошибка сети: ' + e, loader, sendBtn, input); });
|
||||
}
|
||||
|
||||
function pollModifyTask(taskId, errEl, loader, sendBtn, input, instruction) {
|
||||
fetch('/api/tasks/' + taskId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'running' || data.status === 'pending') {
|
||||
setTimeout(function() { pollModifyTask(taskId, errEl, loader, sendBtn, input, instruction); }, 1500);
|
||||
return;
|
||||
}
|
||||
loader.style.display = 'none';
|
||||
sendBtn.disabled = false;
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
|
||||
if (data.status === 'error') {
|
||||
showAiError(errEl, data.error || 'Ошибка модификации', null, null, null);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'done' && data.result) {
|
||||
var newContent = data.result.content;
|
||||
_currentPostContent = newContent;
|
||||
|
||||
// Обновляем текст поста на странице
|
||||
document.getElementById('postContent').textContent = newContent;
|
||||
|
||||
// Добавляем ответ ИИ в чат
|
||||
addChatMsg('ai', newContent, instruction);
|
||||
|
||||
// Убираем ошибку
|
||||
errEl.style.display = 'none';
|
||||
}
|
||||
})
|
||||
.catch(function(e) { showAiError(errEl, 'Ошибка сети: ' + e, null, null, null); });
|
||||
}
|
||||
|
||||
function addChatMsg(role, text, instruction) {
|
||||
var chat = document.getElementById('aiChat');
|
||||
var now = new Date();
|
||||
var dateStr = now.toLocaleTimeString('ru-RU', {hour:'2-digit', minute:'2-digit'});
|
||||
|
||||
var html = '';
|
||||
if (role === 'user') {
|
||||
html = '<div class="ai-chat-msg ai-chat-msg--user">' +
|
||||
'<div class="ai-chat-bubble ai-chat-bubble--user">' + escH(text) + '</div>' +
|
||||
'<div class="ai-chat-time">' + dateStr + '</div></div>';
|
||||
} else {
|
||||
var preview = text.substring(0, 200) + (text.length > 200 ? '…' : '');
|
||||
var fullId = 'aifull-' + Date.now();
|
||||
html = '<div class="ai-chat-msg ai-chat-msg--ai">' +
|
||||
'<div class="ai-chat-meta">🤖 ИИ изменил пост по запросу: «' + escH(instruction || '') + '»</div>' +
|
||||
'<div class="ai-chat-bubble ai-chat-bubble--ai">' +
|
||||
'<div class="ai-chat-preview" id="aiprev-' + fullId + '">' + escH(preview) + '</div>' +
|
||||
'<div class="ai-chat-full" id="' + fullId + '" style="display:none"><pre>' + escH(text) + '</pre></div>' +
|
||||
'<button type="button" class="button-link" onclick="toggleAiFull(\'' + fullId + '\')" style="margin-top:6px;">Показать весь текст</button>' +
|
||||
'</div>' +
|
||||
'<div class="ai-chat-time">' + dateStr + '</div></div>';
|
||||
}
|
||||
|
||||
chat.insertAdjacentHTML('beforeend', html);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
|
||||
function toggleAiFull(id) {
|
||||
var prev = document.getElementById('aiprev-' + id);
|
||||
var full = document.getElementById(id);
|
||||
if (!prev || !full) return;
|
||||
if (full.style.display === 'none') {
|
||||
prev.style.display = 'none';
|
||||
full.style.display = '';
|
||||
} else {
|
||||
full.style.display = 'none';
|
||||
prev.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
function showAiError(el, msg, loader, sendBtn, input) {
|
||||
if (loader) loader.style.display = 'none';
|
||||
if (sendBtn) { sendBtn.disabled = false; }
|
||||
if (input) { input.disabled = false; input.focus(); }
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
function escH(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||
</script>
|
||||
|
||||
<!-- === ГАЛЕРЕЯ ИЗОБРАЖЕНИЙ === -->
|
||||
<div class="card">
|
||||
<h2>🖼 Изображения</h2>
|
||||
{% if images %}
|
||||
<div class="media-grid">
|
||||
{% for img in images %}
|
||||
<div class="media-card">
|
||||
<a href="/media/{{ img.filename }}" class="media-link" target="_blank">
|
||||
<img src="/media/{{ img.filename }}" class="media-thumb" alt="">
|
||||
</a>
|
||||
<div class="media-body">
|
||||
<span class="form-hint">{{ img.source }} · {{ img.created_at }}</span>
|
||||
<form action="/posts/{{ post.id }}/images/{{ img.id }}/delete" method="post" class="inline-form"
|
||||
onsubmit="return confirm('Удалить изображение?')">
|
||||
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="form-hint">Изображений пока нет.</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Загрузка файлов -->
|
||||
<h3>📎 Загрузить изображения</h3>
|
||||
<form method="post" action="/posts/{{ post.id }}/images/upload" enctype="multipart/form-data" class="form">
|
||||
<label>Файлы (можно несколько)
|
||||
<input type="file" name="files" accept="image/*" multiple required>
|
||||
</label>
|
||||
<button type="submit">📎 Загрузить</button>
|
||||
</form>
|
||||
|
||||
<!-- Генерация изображения -->
|
||||
<h3>🎨 Сгенерировать изображение для поста</h3>
|
||||
<div id="postImgError" class="task-error"></div>
|
||||
|
||||
<!-- Лоадер анализа -->
|
||||
<div id="postImgAnalyzeLoader" class="task-loader" style="padding: 20px;">
|
||||
<div class="spinner"></div>
|
||||
<div class="task-loader-text">Анализирую пост...</div>
|
||||
<div class="task-loader-sub">ИИ подбирает лучшие варианты</div>
|
||||
</div>
|
||||
|
||||
<!-- Лоадер генерации -->
|
||||
<div id="postImgGenLoader" class="task-loader" style="padding: 20px;">
|
||||
<div class="spinner"></div>
|
||||
<div class="task-loader-text">Генерирую изображение...</div>
|
||||
<div class="task-loader-sub">Это может занять 15–60 секунд</div>
|
||||
</div>
|
||||
|
||||
<div id="postImgResult" class="task-result"></div>
|
||||
|
||||
<!-- Шаг 1: Выбор концепции -->
|
||||
<div id="postImgConceptsSection">
|
||||
<p class="form-hint" style="margin-bottom: 10px;">ИИ проанализирует пост и предложит варианты:</p>
|
||||
<button type="button" class="copy-btn" id="postImgAnalyzeBtn" onclick="startAnalyzeConcepts()">🤖 ИИ подберёт вариант</button>
|
||||
|
||||
<div style="margin-top: 12px;">
|
||||
<p class="form-hint" style="margin-bottom: 8px;">Или выбери шаблон вручную:</p>
|
||||
<div class="template-grid" id="postImgGrid">
|
||||
{% for t in templates %}
|
||||
<button type="button" class="template-card" data-key="{{ t.key }}"
|
||||
data-prompt="{{ t.prompt }}" data-style="{{ t.style }}" data-size="{{ t.size }}">
|
||||
<span class="template-icon">{{ t.icon }}</span>
|
||||
<span class="template-name">{{ t.name }}</span>
|
||||
<span class="template-hint">{{ t.hint }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 2: Концепции от ИИ (скрыт до анализа) -->
|
||||
<div id="postImgConceptsList" style="display:none;">
|
||||
<h4 style="margin-bottom: 10px;">Выбери вариант:</h4>
|
||||
<div id="postImgConcepts" class="concept-grid"></div>
|
||||
<button type="button" class="button-link" style="margin-top: 10px;" onclick="resetConcepts()">← Назад к шаблонам</button>
|
||||
</div>
|
||||
|
||||
<!-- Скрытая форма для отправки -->
|
||||
<form method="post" action="/posts/{{ post.id }}/images/generate" class="form" id="postImgForm" style="display:none;">
|
||||
<input type="hidden" name="task_type" id="postImgTaskType" value="generate_post_image">
|
||||
<input type="hidden" name="post_id" value="{{ post.id }}">
|
||||
<input type="hidden" name="template_key" id="postImgTemplateKey" value="">
|
||||
<input type="hidden" name="concept_prompt" id="postImgConceptPrompt" value="">
|
||||
<input type="hidden" name="concept_style" id="postImgConceptStyle" value="">
|
||||
<input type="hidden" name="concept_size" id="postImgConceptSize" value="">
|
||||
<button type="submit" id="postImgSubmitBtn">🎨 Сгенерировать</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var form = document.getElementById('postImgForm');
|
||||
var grid = document.getElementById('postImgGrid');
|
||||
if (!form || !grid) return;
|
||||
|
||||
// Клик по шаблону — сразу генерация
|
||||
grid.addEventListener('click', function(e) {
|
||||
var card = e.target.closest('.template-card');
|
||||
if (!card) return;
|
||||
grid.querySelectorAll('.template-card').forEach(function(c) { c.classList.remove('selected'); });
|
||||
card.classList.add('selected');
|
||||
|
||||
document.getElementById('postImgTaskType').value = 'generate_post_image';
|
||||
document.getElementById('postImgTemplateKey').value = card.dataset.key || '';
|
||||
document.getElementById('postImgConceptPrompt').value = '';
|
||||
document.getElementById('postImgConceptStyle').value = '';
|
||||
document.getElementById('postImgConceptSize').value = '';
|
||||
|
||||
runPostImgGeneration('postImgConceptsSection');
|
||||
});
|
||||
})();
|
||||
|
||||
function startAnalyzeConcepts() {
|
||||
var errEl = document.getElementById('postImgError');
|
||||
var loader = document.getElementById('postImgAnalyzeLoader');
|
||||
var section = document.getElementById('postImgConceptsSection');
|
||||
var conceptsList = document.getElementById('postImgConceptsList');
|
||||
|
||||
errEl.style.display = 'none';
|
||||
section.style.display = 'none';
|
||||
conceptsList.style.display = 'none';
|
||||
loader.style.display = 'flex';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('task_type', 'analyze_post_image');
|
||||
fd.append('post_id', '{{ post.id }}');
|
||||
|
||||
fetch('/api/tasks', { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) { showError(errEl, data.error, section, loader); return; }
|
||||
pollAnalyze(data.task_id, errEl, loader, section, conceptsList);
|
||||
})
|
||||
.catch(function(err) { showError(errEl, 'Ошибка сети: ' + err.message, section, loader); });
|
||||
}
|
||||
|
||||
function pollAnalyze(taskId, errEl, loader, section, conceptsList) {
|
||||
fetch('/api/tasks/' + taskId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'running' || data.status === 'pending') {
|
||||
setTimeout(function() { pollAnalyze(taskId, errEl, loader, section, conceptsList); }, 1500);
|
||||
return;
|
||||
}
|
||||
loader.style.display = 'none';
|
||||
if (data.status === 'error') { showError(errEl, data.error, section, null); return; }
|
||||
if (data.status === 'done' && data.result && data.result.concepts) {
|
||||
showConcepts(data.result.concepts, conceptsList, section);
|
||||
}
|
||||
})
|
||||
.catch(function(err) { showError(errEl, 'Ошибка сети: ' + err.message, section, null); });
|
||||
}
|
||||
|
||||
function showConcepts(concepts, container, hiddenSection) {
|
||||
var html = '';
|
||||
concepts.forEach(function(c, i) {
|
||||
var styleLabels = {photo:'Фото',illustration:'Иллюстрация',minimal:'Минимализм','3d':'3D',flat:'Плоская графика'};
|
||||
var sizeLabels = {square:'Квадрат',wide:'Широкий',tall:'Вертикальный'};
|
||||
html += '<button type="button" class="concept-card" onclick="selectConcept(' + i + ')">' +
|
||||
'<div class="concept-title">' + escH(c.title) + '</div>' +
|
||||
'<div class="concept-desc">' + escH(c.description) + '</div>' +
|
||||
'<div class="concept-meta">' +
|
||||
'<span class="concept-tag">' + (styleLabels[c.style] || c.style) + '</span> ' +
|
||||
'<span class="concept-tag">' + (sizeLabels[c.size] || c.size) + '</span>' +
|
||||
'</div></button>';
|
||||
});
|
||||
document.getElementById('postImgConcepts').innerHTML = html;
|
||||
document.getElementById('postImgConceptsList').style.display = 'block';
|
||||
|
||||
// Сохраняем концепции для выбора
|
||||
window._postImgConcepts = concepts;
|
||||
}
|
||||
|
||||
function selectConcept(idx) {
|
||||
var c = window._postImgConcepts[idx];
|
||||
if (!c) return;
|
||||
|
||||
document.getElementById('postImgTaskType').value = 'generate_post_image_with_concept';
|
||||
document.getElementById('postImgTemplateKey').value = '';
|
||||
document.getElementById('postImgConceptPrompt').value = c.description;
|
||||
document.getElementById('postImgConceptStyle').value = c.style || 'none';
|
||||
document.getElementById('postImgConceptSize').value = c.size || 'wide';
|
||||
|
||||
runPostImgGeneration('postImgConceptsList');
|
||||
}
|
||||
|
||||
function runPostImgGeneration(hideSectionId) {
|
||||
var form = document.getElementById('postImgForm');
|
||||
var hideSection = document.getElementById(hideSectionId);
|
||||
var genLoader = document.getElementById('postImgGenLoader');
|
||||
|
||||
if (hideSection) hideSection.style.display = 'none';
|
||||
genLoader.style.display = 'flex';
|
||||
|
||||
var fd = new FormData(form);
|
||||
fetch('/api/tasks', { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) { showError(document.getElementById('postImgError'), data.error, hideSection, genLoader); return; }
|
||||
pollGenerate(data.task_id);
|
||||
})
|
||||
.catch(function(err) { showError(document.getElementById('postImgError'), 'Ошибка сети: ' + err.message, hideSection, genLoader); });
|
||||
}
|
||||
|
||||
function pollGenerate(taskId) {
|
||||
var genLoader = document.getElementById('postImgGenLoader');
|
||||
var errEl = document.getElementById('postImgError');
|
||||
|
||||
fetch('/api/tasks/' + taskId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.status === 'running' || data.status === 'pending') {
|
||||
setTimeout(function() { pollGenerate(taskId); }, 1500);
|
||||
return;
|
||||
}
|
||||
genLoader.style.display = 'none';
|
||||
if (data.status === 'error') {
|
||||
showError(errEl, data.error, document.getElementById('postImgConceptsSection'), null);
|
||||
return;
|
||||
}
|
||||
if (data.status === 'done' && data.result) {
|
||||
var html = '<div class="media-grid"><div class="media-card">' +
|
||||
'<a href="/media/' + data.result.filename + '" class="media-link" target="_blank">' +
|
||||
'<img src="/media/' + data.result.filename + '" class="media-thumb" alt=""></a>' +
|
||||
'<div class="media-body"><span class="form-hint">generated · только что</span></div></div></div>' +
|
||||
'<p style="margin-top:10px;"><button type="button" class="copy-btn" onclick="resetConcepts()">← Ещё вариант</button> ' +
|
||||
'<a href="/images" class="copy-btn">В библиотеку →</a></p>';
|
||||
document.getElementById('postImgResult').innerHTML = html;
|
||||
document.getElementById('postImgResult').style.display = 'block';
|
||||
}
|
||||
})
|
||||
.catch(function(err) { showError(errEl, 'Ошибка сети: ' + err.message, document.getElementById('postImgConceptsSection'), null); });
|
||||
}
|
||||
|
||||
function resetConcepts() {
|
||||
document.getElementById('postImgResult').style.display = 'none';
|
||||
document.getElementById('postImgConceptsList').style.display = 'none';
|
||||
document.getElementById('postImgConceptsSection').style.display = '';
|
||||
document.getElementById('postImgError').style.display = 'none';
|
||||
document.getElementById('postImgGenLoader').style.display = 'none';
|
||||
document.getElementById('postImgAnalyzeLoader').style.display = 'none';
|
||||
}
|
||||
|
||||
function showError(el, msg, showSection, hideLoader) {
|
||||
if (hideLoader) hideLoader.style.display = 'none';
|
||||
if (showSection) showSection.style.display = '';
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
function escH(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div class="post-actions">
|
||||
<button type="button" class="copy-btn" data-copy-target="post-content-{{ post.id }}">📋 Копировать</button>
|
||||
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Изменить</a>
|
||||
<a class="copy-btn" href="/posts/{{ post.id }}/publish">📤 Опубликовать</a>
|
||||
<a class="button-link" href="/posts/{{ post.id }}/edit">✏️ Полное редактирование</a>
|
||||
<form method="post" action="/posts/{{ post.id }}/delete" class="inline-form"
|
||||
onsubmit="return confirm('Удалить этот пост? Действие необратимо.');">
|
||||
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
<p><a href="/history">← Назад к истории</a></p>
|
||||
<p><a href="/history">← Назад к постам</a></p>
|
||||
{% endblock %}
|
||||
@@ -9,17 +9,18 @@
|
||||
<label>Телефон
|
||||
<input type="text" name="phone" value="{{ user.phone }}" required>
|
||||
</label>
|
||||
<label>Вид бизнеса
|
||||
<select name="business_type" required>
|
||||
{% for key, value in business_types.items() %}
|
||||
<option value="{{ key }}" {% if key == user.business_type|string %}selected{% endif %}>{{ value.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Описание бизнеса
|
||||
<textarea name="description" rows="4" required>{{ user.description }}</textarea>
|
||||
</label>
|
||||
<button type="submit">Сохранить</button>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<h2>Провайдер ИИ</h2>
|
||||
{% if provider %}
|
||||
<p><strong>Текущий:</strong> {{ provider_presets[provider.provider_type].name if provider.provider_type in provider_presets else provider.provider_type }}</p>
|
||||
{% else %}
|
||||
<p>Используются глобальные настройки (PiAPI).</p>
|
||||
{% endif %}
|
||||
<a href="/provider" class="copy-btn">⚙ Настроить провайдера</a>
|
||||
</div>
|
||||
|
||||
<p><a href="/dashboard">← Назад в кабинет</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Провайдеры ИИ — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Провайдеры ИИ</h1>
|
||||
<p class="form-hint">Настройте провайдеров для генерации текста и изображений независимо. API ключи хранятся только у вас.</p>
|
||||
|
||||
{% if provider %}
|
||||
<div class="card">
|
||||
<p class="form-hint" style="margin:0">
|
||||
<strong>Текст:</strong> {{ provider_presets[provider.text_provider_type].name if provider.text_provider_type in provider_presets else provider.text_provider_type }} → {{ provider.text_model }}
|
||||
|
|
||||
<strong>Изображения:</strong>
|
||||
{% if provider.image_api_key %}
|
||||
{{ provider_presets[provider.image_provider_type].name if provider.image_provider_type in provider_presets else provider.image_provider_type }} → {{ provider.image_model or '(не указана)' }}
|
||||
{% else %}
|
||||
<em>глобальный PiAPI</em>
|
||||
{% endif %}
|
||||
<span class="form-hint">· {{ provider.updated_at }}</span>
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p style="margin:0">Используются глобальные настройки (PiAPI из .env). Настройте свои провайдеры для независимости.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Табы -->
|
||||
<div class="provider-tabs">
|
||||
<button type="button" class="provider-tab active" onclick="switchTab('text')">✍️ Генерация текста</button>
|
||||
<button type="button" class="provider-tab" onclick="switchTab('image')">🎨 Генерация изображений</button>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/provider" class="form" id="providerForm">
|
||||
|
||||
<!-- === ТАБ: ТЕКСТ === -->
|
||||
<div class="provider-tab-content active" id="tabText">
|
||||
<h2>✍️ Генерация текста</h2>
|
||||
<label>Провайдер
|
||||
<select name="text_provider_type" id="textProviderType" required onchange="updatePresets('text')">
|
||||
<option value="" disabled {% if not provider and not form %}selected{% endif %}>Выберите провайдера</option>
|
||||
{% for key, preset in provider_presets.items() %}
|
||||
<option value="{{ key }}"
|
||||
{% if (provider and provider.text_provider_type == key) or (form and form.text_provider_type == key) %}selected{% endif %}>
|
||||
{{ preset.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>API ключ
|
||||
<input type="password" name="text_api_key" id="textApiKey"
|
||||
value="{{ form.text_api_key if form else '' }}"
|
||||
placeholder="Ваш API ключ">
|
||||
</label>
|
||||
<label style="flex-direction: row; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" name="text_use_global" value="1" id="textUseGlobal"
|
||||
onchange="toggleGlobal('text')">
|
||||
<span>Использовать глобальные настройки (PiAPI из .env)</span>
|
||||
</label>
|
||||
|
||||
<label>Base URL
|
||||
<input type="text" name="text_base_url" id="textBaseUrl"
|
||||
value="{{ form.text_base_url if form else (provider.text_base_url if provider else '') }}"
|
||||
placeholder="https://...">
|
||||
</label>
|
||||
|
||||
<label>Модель
|
||||
<input type="text" name="text_model" id="textModel"
|
||||
value="{{ form.text_model if form else (provider.text_model if provider else '') }}"
|
||||
placeholder="напр. gpt-4o, glm-5.2, claude-sonnet-5">
|
||||
</label>
|
||||
|
||||
<div class="card" style="margin-top:15px; background: var(--bg-secondary);">
|
||||
<p style="margin:0; font-size: 13px;"><strong>Популярные модели для текста:</strong></p>
|
||||
<ul style="margin:5px 0 0 18px; font-size: 13px;">
|
||||
<li><code>gpt-4o</code> — OpenAI, OpenRouter, PiAPI</li>
|
||||
<li><code>claude-sonnet-5</code> — Anthropic через OpenRouter</li>
|
||||
<li><code>glm-5.2</code> — OpenCode Zen (платная)</li>
|
||||
<li><code>deepseek-v4-flash-free</code> — OpenCode Zen (бесплатная)</li>
|
||||
<li><code>hy3-free</code> — OpenCode Zen (бесплатная, reasoning)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === ТАБ: ИЗОБРАЖЕНИЯ === -->
|
||||
<div class="provider-tab-content" id="tabImage">
|
||||
<h2>🎨 Генерация изображений</h2>
|
||||
<label>Провайдер
|
||||
<select name="image_provider_type" id="imageProviderType" required onchange="updatePresets('image')">
|
||||
<option value="" disabled {% if not provider and not form %}selected{% endif %}>Выберите провайдера</option>
|
||||
{% for key, preset in provider_presets.items() %}
|
||||
{% if preset.supports_images %}
|
||||
<option value="{{ key }}"
|
||||
{% if (provider and provider.image_provider_type == key) or (form and form.image_provider_type == key) %}selected{% endif %}>
|
||||
{{ preset.name }}
|
||||
</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>API ключ
|
||||
<input type="password" name="image_api_key" id="imageApiKey"
|
||||
value="{{ form.image_api_key if form else '' }}"
|
||||
placeholder="Ваш API ключ">
|
||||
</label>
|
||||
<label style="flex-direction: row; align-items: center; gap: 8px;">
|
||||
<input type="checkbox" name="image_use_global" value="1" id="imageUseGlobal"
|
||||
onchange="toggleGlobal('image')">
|
||||
<span>Использовать глобальные настройки (PiAPI из .env)</span>
|
||||
</label>
|
||||
|
||||
<label>Base URL
|
||||
<input type="text" name="image_base_url" id="imageBaseUrl"
|
||||
value="{{ form.image_base_url if form else (provider.image_base_url if provider else '') }}"
|
||||
placeholder="https://...">
|
||||
</label>
|
||||
|
||||
<label>Модель
|
||||
<input type="text" name="image_model" id="imageModel"
|
||||
value="{{ form.image_model if form else (provider.image_model if provider else '') }}"
|
||||
placeholder="напр. gpt-image-1, dall-e-3">
|
||||
</label>
|
||||
|
||||
<div class="card" style="margin-top:15px; background: var(--bg-secondary);">
|
||||
<p style="margin:0; font-size: 13px;"><strong>Популярные модели для изображений:</strong></p>
|
||||
<ul style="margin:5px 0 0 18px; font-size: 13px;">
|
||||
<li><code>gpt-image-1</code> — OpenAI, PiAPI (рекомендуется)</li>
|
||||
<li><code>gpt-image-1-mini</code> — OpenAI (быстрая, дешевле)</li>
|
||||
<li><code>dall-e-3</code> — OpenAI (старая, через прямой API)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Сохранить настройки</button>
|
||||
</form>
|
||||
|
||||
<p style="margin-top:20px"><a href="/profile/edit">← Назад в профиль</a> · <a href="/dashboard">← На главную</a></p>
|
||||
|
||||
<script>
|
||||
const PRESETS = {
|
||||
{% for key, preset in provider_presets.items() %}
|
||||
"{{ key }}": {"base_url": "{{ preset.base_url }}", "text_model": "{{ preset.text_model }}", "image_model": "{{ preset.image_model }}"},
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
function switchTab(tab) {
|
||||
// Переключаем кнопки
|
||||
document.querySelectorAll('.provider-tab').forEach(function(btn) { btn.classList.remove('active'); });
|
||||
document.querySelectorAll('.provider-tab-content').forEach(function(c) { c.classList.remove('active'); });
|
||||
// Активируем нужный таб
|
||||
if (tab === 'text') {
|
||||
document.querySelectorAll('.provider-tab')[0].classList.add('active');
|
||||
document.getElementById('tabText').classList.add('active');
|
||||
} else {
|
||||
document.querySelectorAll('.provider-tab')[1].classList.add('active');
|
||||
document.getElementById('tabImage').classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePresets(kind) {
|
||||
var typeEl = document.getElementById(kind + "ProviderType");
|
||||
var preset = PRESETS[typeEl.value];
|
||||
if (!preset) return;
|
||||
document.getElementById(kind + "BaseUrl").value = preset.base_url;
|
||||
if (kind === "text") document.getElementById(kind + "Model").value = preset.text_model;
|
||||
if (kind === "image") document.getElementById(kind + "Model").value = preset.image_model;
|
||||
var globalCb = document.getElementById(kind + "UseGlobal");
|
||||
if (globalCb && globalCb.checked) {
|
||||
globalCb.checked = false;
|
||||
toggleGlobal(kind);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGlobal(kind) {
|
||||
var cb = document.getElementById(kind + "UseGlobal");
|
||||
var key = document.getElementById(kind + "ApiKey");
|
||||
var baseUrl = document.getElementById(kind + "BaseUrl");
|
||||
var model = document.getElementById(kind + "Model");
|
||||
if (cb.checked) {
|
||||
key.disabled = true;
|
||||
key.placeholder = "Используются глобальные настройки";
|
||||
key.value = "";
|
||||
baseUrl.value = "https://api.piapi.ai/v1";
|
||||
model.value = (kind === "text") ? "gpt-4o" : "gpt-image-1";
|
||||
} else {
|
||||
key.disabled = false;
|
||||
key.placeholder = "Ваш API ключ";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Опубликовать пост — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Публикация поста</h1>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>Платформа:</strong> {{ platform_label(post.platform) }}</p>
|
||||
<p><strong>Создан:</strong> {{ post.created_at }}</p>
|
||||
{% if post.published %}
|
||||
<p><strong>Статус:</strong> ✅ Опубликован в «{{ post.published_to }}» {{ post.published_at }}</p>
|
||||
{% else %}
|
||||
<p><strong>Статус:</strong> ⏳ Не опубликован</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Текст поста</h2>
|
||||
<div class="result">{{ post.content }}</div>
|
||||
</div>
|
||||
|
||||
{% if channels %}
|
||||
<form method="post" action="/posts/{{ post.id }}/publish" class="form">
|
||||
<label>Канал для публикации
|
||||
<select name="channel_id" required>
|
||||
<option value="" disabled selected>Выберите канал или чат</option>
|
||||
{% for ch in channels %}
|
||||
<option value="{{ ch.id }}">{{ ch.title }} ({{ ch.chat_type }}){% if ch.username %} @{{ ch.username }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">📤 Опубликовать</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p>⚠️ Нет добавленных каналов. <a href="/channels/add">Добавьте канал →</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-actions">
|
||||
<a href="/posts/{{ post.id }}" class="copy-btn">← Назад к посту</a>
|
||||
<a href="/channels" class="copy-btn">⚙ Управление каналами</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -9,17 +9,6 @@
|
||||
<label>Телефон
|
||||
<input type="text" name="phone" value="{{ form.phone if form else '' }}" required>
|
||||
</label>
|
||||
<label>Вид бизнеса
|
||||
<select name="business_type" required>
|
||||
<option value="" disabled {% if not form %}selected{% endif %}>Выбери вид бизнеса</option>
|
||||
{% for key, value in business_types.items() %}
|
||||
<option value="{{ key }}" {% if form and form.business_type == key %}selected{% endif %}>{{ value.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Описание бизнеса
|
||||
<textarea name="description" rows="4" required>{{ form.description if form else '' }}</textarea>
|
||||
</label>
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ form.email if form else '' }}" required>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Опрос медиа-образа — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Опрос для создания медиа-образа</h1>
|
||||
<p class="form-hint">Заполните поля по шагам. На последнем шаге можно сохранить и сгенерировать документ стратегии.</p>
|
||||
|
||||
<!-- === АНАЛИЗ ПРОФИЛЯ СОЦСЕТИ === -->
|
||||
<div class="card" id="socialAnalyzer">
|
||||
<h2 style="margin-top:0">🔍 Автозаполнение из профиля соцсети</h2>
|
||||
<p class="form-hint">Вставьте ссылку на профиль или канал — ИИ проанализирует посты и заполнит поля медиа-образа. Поддерживается Telegram (автоматически), VK, Tenchat, Instagram (вручную).</p>
|
||||
|
||||
<div id="socialTaskError" class="task-error"></div>
|
||||
<div id="socialTaskLoader" class="task-loader" style="display:none; padding:15px">
|
||||
<div class="spinner"></div>
|
||||
<div class="task-loader-text">Анализирую посты...</div>
|
||||
<div class="task-loader-sub">Это может занять 20–60 секунд</div>
|
||||
</div>
|
||||
<div id="socialTaskResult" class="task-result" style="display:none"></div>
|
||||
|
||||
<div id="socialForm">
|
||||
<label>Ссылка на профиль/канал
|
||||
<input type="url" id="socialUrl" placeholder="https://t.me/channel, https://vk.com/username, https://tenchat.ru/username">
|
||||
</label>
|
||||
|
||||
<div id="manualPostsSection" style="display:none">
|
||||
<label>Тексты постов <span class="form-hint">Вставьте 3-10 постов. Разделяйте пустой строкой.</span>
|
||||
<textarea id="manualPosts" rows="8" placeholder="Пост 1 текст... Пост 2 текст... Пост 3 текст..."></textarea>
|
||||
</label>
|
||||
<p class="form-hint" id="manualHint"></p>
|
||||
</div>
|
||||
|
||||
<button type="button" class="copy-btn" onclick="startSocialAnalysis()" id="socialAnalyzeBtn">🔍 Анализировать профиль</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('socialUrl').addEventListener('input', function() {
|
||||
var url = this.value.trim().toLowerCase();
|
||||
var isTG = /t\.me\//.test(url);
|
||||
var section = document.getElementById('manualPostsSection');
|
||||
var hint = document.getElementById('manualHint');
|
||||
if (url && !isTG) {
|
||||
section.style.display = '';
|
||||
hint.textContent = 'Для VK, Tenchat и других платформ: откройте профиль, скопируйте 3-10 постов и вставьте сюда.';
|
||||
} else {
|
||||
section.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
function startSocialAnalysis() {
|
||||
var url = document.getElementById('socialUrl').value.trim();
|
||||
if (!url) { alert('Вставьте ссылку на профиль'); return; }
|
||||
var manualPosts = document.getElementById('manualPosts') ? document.getElementById('manualPosts').value : '';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('task_type', 'analyze_social_profile');
|
||||
fd.append('url', url);
|
||||
fd.append('manual_posts', manualPosts);
|
||||
|
||||
document.getElementById('socialForm').style.display = 'none';
|
||||
document.getElementById('socialTaskLoader').style.display = 'flex';
|
||||
document.getElementById('socialTaskError').style.display = 'none';
|
||||
document.getElementById('socialTaskResult').style.display = 'none';
|
||||
|
||||
fetch('/api/tasks', {method: 'POST', body: fd})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var taskId = data.task_id;
|
||||
var poll = setInterval(function() {
|
||||
fetch('/api/tasks/' + taskId)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(t) {
|
||||
if (t.status === 'done') {
|
||||
clearInterval(poll);
|
||||
document.getElementById('socialTaskLoader').style.display = 'none';
|
||||
onSocialAnalysisDone(t.result);
|
||||
} else if (t.status === 'error') {
|
||||
clearInterval(poll);
|
||||
document.getElementById('socialTaskLoader').style.display = 'none';
|
||||
document.getElementById('socialTaskError').textContent = t.error || 'Ошибка анализа';
|
||||
document.getElementById('socialTaskError').style.display = 'block';
|
||||
document.getElementById('socialForm').style.display = '';
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
})
|
||||
.catch(function(e) {
|
||||
document.getElementById('socialTaskLoader').style.display = 'none';
|
||||
document.getElementById('socialTaskError').textContent = 'Ошибка сети: ' + e;
|
||||
document.getElementById('socialTaskError').style.display = 'block';
|
||||
document.getElementById('socialForm').style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
function onSocialAnalysisDone(result) {
|
||||
var info = result.platform_info || {};
|
||||
var count = result.posts_count || 0;
|
||||
var html = '<div class="card"><p>✅ Проанализировано <strong>' + count + ' постов</strong> из ' + (info.platform || 'профиля') + ' (@' + (info.channel || '') + ')</p>';
|
||||
html += '<button type="button" class="copy-btn" onclick="applySocialData()">✅ Применить к медиа-образу</button>';
|
||||
html += ' <button type="button" class="button-link" onclick="resetSocialForm()">↩ Отмена</button></div>';
|
||||
document.getElementById('socialTaskResult').innerHTML = html;
|
||||
document.getElementById('socialTaskResult').style.display = '';
|
||||
window._socialData = result;
|
||||
}
|
||||
|
||||
function resetSocialForm() {
|
||||
document.getElementById('socialForm').style.display = '';
|
||||
document.getElementById('socialTaskResult').style.display = 'none';
|
||||
document.getElementById('socialTaskResult').innerHTML = '';
|
||||
}
|
||||
|
||||
function applySocialData() {
|
||||
var d = window._socialData;
|
||||
if (!d) return;
|
||||
// Заполняем поля формы
|
||||
setVal('client_name', d.client_name);
|
||||
setVal('client_role', d.client_role);
|
||||
setVal('brand_essence', d.brand_essence);
|
||||
setVal('current_situation', d.current_situation);
|
||||
setVal('positioning_who', d.positioning_who);
|
||||
setVal('positioning_problem', d.positioning_problem);
|
||||
setVal('positioning_result', d.positioning_result);
|
||||
setVal('tov_description', d.tov_description);
|
||||
setVal('tov_references', d.tov_references);
|
||||
setVal('tov_axes', d.tov_axes);
|
||||
setVal('prompt_directions', d.prompt_directions);
|
||||
setVal('prompt_structure', d.prompt_structure);
|
||||
setVal('prompt_formats', d.prompt_formats);
|
||||
// Сворачиваем анализатор
|
||||
document.getElementById('socialAnalyzer').style.display = 'none';
|
||||
// Переходим к шагу 1
|
||||
showStep(1);
|
||||
}
|
||||
|
||||
function setVal(name, value) {
|
||||
if (!value) return;
|
||||
// AI может вернуть массив — превращаем в строки через перенос
|
||||
if (Array.isArray(value)) {
|
||||
value = value.join('\n');
|
||||
}
|
||||
var el = document.querySelector('[name="' + name + '"]');
|
||||
if (el) {
|
||||
el.value = value;
|
||||
el.style.borderColor = 'var(--accent)';
|
||||
setTimeout(function() { el.style.borderColor = ''; }, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- === КОНЕЦ АНАЛИЗАТОРА === -->
|
||||
|
||||
<div class="survey-wizard">
|
||||
<!-- Прогресс -->
|
||||
<div class="survey-progress">
|
||||
<div class="survey-progress-bar" id="progressBar"></div>
|
||||
</div>
|
||||
<div class="survey-steps-indicator" id="stepsIndicator"></div>
|
||||
|
||||
<form id="surveyForm">
|
||||
|
||||
<!-- Шаг 1: Основная информация -->
|
||||
<div class="survey-step" data-step="1">
|
||||
<h2>Шаг 1 — Основная информация</h2>
|
||||
<div class="form">
|
||||
<label>Тип медиа-образа
|
||||
<select name="brand_type" id="brandType" onchange="toggleBrandType()">
|
||||
<option value="personal">Личный бренд</option>
|
||||
<option value="organization">Компания / бренд</option>
|
||||
<option value="community">Общественная организация</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Личный бренд -->
|
||||
<div id="fieldsPersonal">
|
||||
<label>Имя клиента <input type="text" name="client_name" placeholder="Напр. Кирилл Бойко"></label>
|
||||
<label>Профессия / роль <input type="text" name="client_role" placeholder="Напр. IT-эксперт, предприниматель"></label>
|
||||
</div>
|
||||
|
||||
<!-- Организация -->
|
||||
<div id="fieldsOrganization" style="display:none">
|
||||
<label>Название организации <input type="text" name="org_name" placeholder="Напр. ТехноСтарт"></label>
|
||||
<label>Сфера деятельности <input type="text" name="org_industry" placeholder="Напр. IT-консалтинг, разработка ПО"></label>
|
||||
<label>Миссия организации <textarea name="org_mission" rows="2" placeholder="Напр. Помогаем бизнесу расти через технологии"></textarea></label>
|
||||
<label>Ключевые продукты / услуги <textarea name="org_products" rows="2" placeholder="Напр. Разработка мобильных приложений, IT-аудит"></textarea></label>
|
||||
<label>Количество сотрудников <input type="text" name="org_team_size" placeholder="Напр. 15-30"></label>
|
||||
<label>Год основания <input type="text" name="org_founded" placeholder="Напр. 2018"></label>
|
||||
</div>
|
||||
|
||||
<!-- Общественная организация -->
|
||||
<div id="fieldsCommunity" style="display:none">
|
||||
<label>Название организации <input type="text" name="org_name" placeholder="Напр. Сообщество молодых предпринимателей"></label>
|
||||
<label>Сфера деятельности <input type="text" name="org_industry" placeholder="Напр. Бизнес-сообщество, поддержка молодых предпринимателей"></label>
|
||||
<label>Миссия организации <textarea name="org_mission" rows="2" placeholder="Напр. Объединять молодых предпринимателей для обмена опытом и совместного роста"></textarea></label>
|
||||
<label>Ключевые направления деятельности <textarea name="org_products" rows="2" placeholder="Напр. Мероприятия, менторство, нетворкинг, образовательные программы"></textarea></label>
|
||||
<label>Количество участников <input type="text" name="org_team_size" placeholder="Напр. 200+"></label>
|
||||
<label>Год основания <input type="text" name="org_founded" placeholder="Напр. 2022"></label>
|
||||
<label>Формат участия <input type="text" name="org_format" placeholder="Напр. Бесплатное членство, ежемесячные встречи"></label>
|
||||
</div>
|
||||
|
||||
<label>Город / регион <input type="text" name="client_location" placeholder="Напр. Донецк"></label>
|
||||
<label>Описание текущей ситуации <textarea name="current_situation" rows="5" placeholder="Кто он/оно, чем занимается, какой уникальный опыт, текущий статус..."></textarea></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 2: Цели -->
|
||||
<div class="survey-step" data-step="2" hidden>
|
||||
<h2>Шаг 2 — Цели на 12 месяцев</h2>
|
||||
<div class="form">
|
||||
<label>Целевое число подписчиков <input type="number" name="goal_audience_number" placeholder="10000"></label>
|
||||
<label>Основная платформа <input type="text" name="goal_audience_platform" placeholder="Напр. Telegram-канал"></label>
|
||||
<label>Описание целевой аудитории <textarea name="goal_audience_description" rows="3" placeholder="Кто активная аудитория..."></textarea></label>
|
||||
<label>Область экспертизы <input type="text" name="goal_expert_field" placeholder="Напр. цифровизация МСП"></label>
|
||||
<label>Целевое кол-во приглашений на выступления в год <input type="number" name="goal_expert_speaking" placeholder="5"></label>
|
||||
<label>Целевое кол-во заявок в месяц <input type="number" name="goal_commercial_leads" placeholder="5"></label>
|
||||
<label>Какие услуги <textarea name="goal_commercial_services" rows="2" placeholder="Напр. IT-аудит, внедрение"></textarea></label>
|
||||
<label>Описание контент-системы <textarea name="goal_content_system" rows="3" placeholder="Напр. Ежедневный контент-план на трёх столпах..."></textarea></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 3: Позиционирование -->
|
||||
<div class="survey-step" data-step="3" hidden>
|
||||
<h2>Шаг 3 — Позиционирование и УТП</h2>
|
||||
<div class="form">
|
||||
<label>Сущность бренда (одной строкой) <input type="text" name="brand_essence" placeholder="Напр. Цифровой архитектор, строящий IT-устойчивость бизнеса"></label>
|
||||
|
||||
<!-- Личный бренд -->
|
||||
<div class="brand-fields-personal">
|
||||
<label>Кому я помогаю? <textarea name="positioning_who" rows="2"></textarea></label>
|
||||
<label>Какую проблему решаю? <textarea name="positioning_problem" rows="2"></textarea></label>
|
||||
<label>Какой результат получает клиент? <textarea name="positioning_result" rows="2"></textarea></label>
|
||||
</div>
|
||||
|
||||
<!-- Организация -->
|
||||
<div class="brand-fields-organization" style="display:none">
|
||||
<label>Какую проблему решает наш продукт/услуга? <textarea name="positioning_problem" rows="2"></textarea></label>
|
||||
<label>Ценность для клиента <textarea name="positioning_result" rows="2" placeholder="Какую ценность получает клиент от нашей организации?"></textarea></label>
|
||||
<label>Кому мы помогаем (целевой сегмент)? <textarea name="positioning_who" rows="2" placeholder="B2B / B2C, отрасли, размер бизнеса..."></textarea></label>
|
||||
<label>Почему выбирают нас, а не конкурентов? <textarea name="positioning_difference" rows="2"></textarea></label>
|
||||
</div>
|
||||
|
||||
<!-- Общественная организация -->
|
||||
<div class="brand-fields-community" style="display:none">
|
||||
<label>Какую проблему решает наше сообщество? <textarea name="positioning_problem" rows="2" placeholder="Изоляция молодых предпринимателей, отсутствие наставничества, слабый нетворкинг..."></textarea></label>
|
||||
<label>Ценность для участника <textarea name="positioning_result" rows="2" placeholder="Что получает каждый участник сообщества?"></textarea></label>
|
||||
<label>Для кого создано сообщество? <textarea name="positioning_who" rows="2" placeholder="Молодые предприниматели, стартаперы, начинающие бизнесмены..."></textarea></label>
|
||||
<label>Чем отличаемся от других сообществ и ассоциаций? <textarea name="positioning_difference" rows="2"></textarea></label>
|
||||
<label>Форматы взаимодействия <textarea name="community_formats" rows="2" placeholder="Митапы, оффлайн-встречи, онлайн-чаты, менторские сессии..."></textarea></label>
|
||||
</div>
|
||||
|
||||
<label>УТП <textarea name="usp" rows="3" placeholder="Уникальное торговое предложение..."></textarea></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 4: Аудитории и каналы -->
|
||||
<div class="survey-step" data-step="4" hidden>
|
||||
<h2>Шаг 4 — Аудитории и каналы</h2>
|
||||
<div class="form">
|
||||
<label>Идеальные подписчики / клиенты <span class="form-hint">По строкам</span>
|
||||
<textarea name="ideal_audiences" rows="5" placeholder="Владельцы МСП... IT-специалисты... Активная молодёжь..."></textarea>
|
||||
</label>
|
||||
<div>
|
||||
<strong>Каналы коммуникации</strong>
|
||||
<table class="table" id="channelsTable">
|
||||
<thead><tr><th>Платформа</th><th>Тип</th><th>Частота</th><th>Описание</th><th></th></tr></thead>
|
||||
<tbody id="channelsBody"></tbody>
|
||||
</table>
|
||||
<button type="button" class="copy-btn" onclick="addChannel()">+ Добавить канал</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 5: Визуальная идентичность -->
|
||||
<div class="survey-step" data-step="5" hidden>
|
||||
<h2>Шаг 5 — Визуальная идентичность</h2>
|
||||
<div class="form">
|
||||
<label>Концепция <input type="text" name="visual_concept" placeholder="Напр. Цифровой архитектор"></label>
|
||||
<h3>Цветовая палитра</h3>
|
||||
<label>Основной цвет — название <input type="text" name="color_main_name" placeholder="Напр. Глубокий антрацитово-синий"></label>
|
||||
<label>Основной цвет — HEX <input type="text" name="color_main_hex" placeholder="#0A1A2F"></label>
|
||||
<label>Акцент 1 — название <input type="text" name="color_accent1_name" placeholder="Напр. Электрический бирюзовый"></label>
|
||||
<label>Акцент 1 — HEX <input type="text" name="color_accent1_hex" placeholder="#00E5CC"></label>
|
||||
<label>Акцент 2 — название <input type="text" name="color_accent2_name" placeholder="Напр. Терракотовый"></label>
|
||||
<label>Акцент 2 — HEX <input type="text" name="color_accent2_hex" placeholder="#FF6B4A"></label>
|
||||
<label>Нейтральная база <input type="text" name="color_neutral" placeholder="Напр. Светло-серый, белый, графит"></label>
|
||||
<h3>Типографика</h3>
|
||||
<label>Заголовки <input type="text" name="typography_headings" placeholder="Напр. Montserrat / Gilroy"></label>
|
||||
<label>Текст <input type="text" name="typography_body" placeholder="Напр. Open Sans / Inter"></label>
|
||||
<label>Акценты <input type="text" name="typography_accents" placeholder="Напр. Roboto Mono"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 6: Tone of Voice -->
|
||||
<div class="survey-step" data-step="6" hidden>
|
||||
<h2>Шаг 6 — Tone of Voice</h2>
|
||||
<div class="form">
|
||||
<label>Описание тона <input type="text" name="tov_description" placeholder="Напр. Стратег-прагматик с душой"></label>
|
||||
<label>Референсы / вдохновители <textarea name="tov_references" rows="3" placeholder="Напр. Уверенность Лаврова, глубина Гео, яркость Минаева..."></textarea></label>
|
||||
<label>Оси ToV <span class="form-hint">По строкам</span>
|
||||
<textarea name="tov_axes" rows="4" placeholder="1) Энергичная уверенность 2) Структурированная спонтанность 3) Искренность 4) Ответственный взгляд лидера"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 7: Контент-стратегия -->
|
||||
<div class="survey-step" data-step="7" hidden>
|
||||
<h2>Шаг 7 — Контент-стратегия: Три столпа</h2>
|
||||
<div class="form">
|
||||
<!-- Личный бренд -->
|
||||
<div class="brand-fields-personal">
|
||||
<h3>Столп 1 — «Кто я» (Личность, Доверие)</h3>
|
||||
<label>Название <input type="text" name="pillar1_name" value="Кто я"></label>
|
||||
</div>
|
||||
<div class="brand-fields-organization" style="display:none">
|
||||
<h3>Столп 1 — «О продукте» (Компания, Доверие)</h3>
|
||||
<label>Название <input type="text" name="pillar1_name" value="О продукте"></label>
|
||||
</div>
|
||||
<div class="brand-fields-community" style="display:none">
|
||||
<h3>Столп 1 — «О миссии» (Сообщество, Ценности)</h3>
|
||||
<label>Название <input type="text" name="pillar1_name" value="О миссии"></label>
|
||||
</div>
|
||||
|
||||
<label>Цель <textarea name="pillar1_goal" rows="2" placeholder="Создать эмоциональную связь..."></textarea></label>
|
||||
<label>Ключевые форматы <span class="form-hint">По строкам</span>
|
||||
<textarea name="pillar1_formats" rows="3" class="pillar1-formats-hint" placeholder="История пути... Закулисье... Общественная деятельность..."></textarea>
|
||||
</label>
|
||||
<label>Частота <input type="text" name="pillar1_frequency" value="30%"></label>
|
||||
|
||||
<!-- Личный бренд -->
|
||||
<div class="brand-fields-personal">
|
||||
<h3>Столп 2 — «Решаю боль» (Экспертиза, Польза)</h3>
|
||||
<label>Название <input type="text" name="pillar2_name" value="Решаю боль"></label>
|
||||
</div>
|
||||
<div class="brand-fields-organization" style="display:none">
|
||||
<h3>Столп 2 — «Экспертиза» (Знания, Авторитет)</h3>
|
||||
<label>Название <input type="text" name="pillar2_name" value="Экспертиза"></label>
|
||||
</div>
|
||||
<div class="brand-fields-community" style="display:none">
|
||||
<h3>Столп 2 — «Мероприятия» (Активности, Результат)</h3>
|
||||
<label>Название <input type="text" name="pillar2_name" value="Мероприятия"></label>
|
||||
</div>
|
||||
|
||||
<label>Цель <textarea name="pillar2_goal" rows="2" placeholder="Закрепить статус эксперта..."></textarea></label>
|
||||
<label>Ключевые форматы <span class="form-hint">По строкам</span>
|
||||
<textarea name="pillar2_formats" rows="3" class="pillar2-formats-hint" placeholder="Экспертные разборы... Практические советы... Кейсы..."></textarea>
|
||||
</label>
|
||||
<label>Частота <input type="text" name="pillar2_frequency" value="50%"></label>
|
||||
|
||||
<!-- Личный бренд -->
|
||||
<div class="brand-fields-personal">
|
||||
<h3>Столп 3 — «Втягиваю» (Вовлечение, Диалог)</h3>
|
||||
<label>Название <input type="text" name="pillar3_name" value="Втягиваю"></label>
|
||||
</div>
|
||||
<div class="brand-fields-organization" style="display:none">
|
||||
<h3>Столп 3 — «Вовлечение» (Сообщество, Лояльность)</h3>
|
||||
<label>Название <input type="text" name="pillar3_name" value="Вовлечение"></label>
|
||||
</div>
|
||||
<div class="brand-fields-community" style="display:none">
|
||||
<h3>Столп 3 — «Участники» (Истории, Вовлечение)</h3>
|
||||
<label>Название <input type="text" name="pillar3_name" value="Участники"></label>
|
||||
</div>
|
||||
|
||||
<label>Цель <textarea name="pillar3_goal" rows="2" placeholder="Превратить пассивную аудиторию в активное сообщество..."></textarea></label>
|
||||
<label>Ключевые форматы <span class="form-hint">По строкам</span>
|
||||
<textarea name="pillar3_formats" rows="3" class="pillar3-formats-hint" placeholder="Опросы... Прямые эфиры... Интерактивные челленджи..."></textarea>
|
||||
</label>
|
||||
<label>Частота <input type="text" name="pillar3_frequency" value="20%"></label>
|
||||
|
||||
<label>Особые форматы <textarea name="special_formats" rows="2" placeholder="Напр. Живые видеокружочки..."></textarea></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 8: Воронка и продукты -->
|
||||
<div class="survey-step" data-step="8" hidden>
|
||||
<h2>Шаг 8 — Воронка и продуктовый ряд</h2>
|
||||
<div class="form">
|
||||
<h3>Воронка вовлечения</h3>
|
||||
<div id="funnelContainer">
|
||||
<!-- 5 этапов -->
|
||||
</div>
|
||||
<h3>Продуктовый ряд</h3>
|
||||
<label>Входная точка (Lead Magnet) <textarea name="product_lead_magnet" rows="2" placeholder="Напр. Бесплатный PDF..."></textarea></label>
|
||||
<label>Самая простая услуга <textarea name="product_simple" rows="2"></textarea></label>
|
||||
<label>Услуга-конвертер <textarea name="product_converter" rows="2"></textarea></label>
|
||||
<label>Комплексные решения <textarea name="product_complex" rows="2"></textarea></label>
|
||||
<label>Экспертные продукты <textarea name="product_expert" rows="2"></textarea></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 9: Метрики -->
|
||||
<div class="survey-step" data-step="9" hidden>
|
||||
<h2>Шаг 9 — Метрики</h2>
|
||||
<div class="form">
|
||||
<label>Количественные метрики <span class="form-hint">По строкам, формат: Название | Целевое значение | Измерение</span>
|
||||
<textarea name="metrics_quantitative" rows="5" placeholder="Подписчики в Telegram | 10000 | Статистика канала Ежемесячный охват | 250000 | Аналитика соцсетей"></textarea>
|
||||
</label>
|
||||
<label>Качественные метрики <span class="form-hint">Тот же формат</span>
|
||||
<textarea name="metrics_qualitative" rows="5" placeholder="Уровень вовлеченности (ER) | > 7% | Формула Количество приглашений как спикера | 5-7 в год | Календарь"></textarea>
|
||||
</label>
|
||||
<label>Коммерческие метрики <span class="form-hint">Тот же формат</span>
|
||||
<textarea name="metrics_commercial" rows="5" placeholder="Заявки с пометкой «от Кирилла» | 5 в месяц | CRM Конверсия из лида в продажу | 20% | Отчёт"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 10: Риски и рекомендации -->
|
||||
<div class="survey-step" data-step="10" hidden>
|
||||
<h2>Шаг 10 — Риски и рекомендации</h2>
|
||||
<div class="form">
|
||||
<label>Потенциальные риски <span class="form-hint">По строкам, формат: Риск | Решение</span>
|
||||
<textarea name="risks" rows="5" placeholder="Выгорание от ежедневного контента | Заранее создать банк контента... Конфликт образов | Связать через общую миссию..."></textarea>
|
||||
</label>
|
||||
<label>Рекомендации <span class="form-hint">По строкам</span>
|
||||
<textarea name="recommendations" rows="5" placeholder="Быть консистентным... Делегировать техническую часть... Измерять и адаптироваться..."></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 11: Архетипы -->
|
||||
<div class="survey-step" data-step="11" hidden>
|
||||
<h2>Шаг 11 — Архетипический образ</h2>
|
||||
<div class="form">
|
||||
<h3>Ключевой архетип</h3>
|
||||
<label>Название <input type="text" name="archetype_key_name" placeholder="Напр. Ведущий-Герой"></label>
|
||||
<label>Суть <textarea name="archetype_key_essence" rows="3"></textarea></label>
|
||||
<label>Проявления <span class="form-hint">По строкам</span>
|
||||
<textarea name="archetype_key_manifestations" rows="5" placeholder="Миссия: ... Роли: ... Действия: ... Принцип: ... Визуальные коды: ..."></textarea>
|
||||
</label>
|
||||
<h3>Вспомогательные архетипы</h3>
|
||||
<label>Вспомогательные архетипы <span class="form-hint">По строкам, формат: Название | Суть | Проявления</span>
|
||||
<textarea name="archetype_supporting" rows="5" placeholder="Творец | Инноватор... | Создание экосистемы... Заботливый | Альтруист... | Помощь молодым..."></textarea>
|
||||
</label>
|
||||
<label>Синтез архетипов <textarea name="archetype_synthesis" rows="3" placeholder="Итоговый образ..."></textarea></label>
|
||||
<label>Таблица проявлений <span class="form-hint">По строкам, формат: Ситуация | Архетип | Как выглядит</span>
|
||||
<textarea name="archetype_table" rows="5" placeholder="Выступление на форуме | Ведущий-Герой | Чёткая структура... Разбор провала | Творец+Герой | Анализ ошибок..."></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаг 12: Промпт -->
|
||||
<div class="survey-step" data-step="12" hidden>
|
||||
<h2>Шаг 12 — Промпт для генерации контента</h2>
|
||||
<div class="form">
|
||||
<div class="brand-fields-personal">
|
||||
<label>Контекст и личность <textarea name="prompt_context" rows="5" placeholder="Кто я, мой бренд, мои роли..."></textarea></label>
|
||||
</div>
|
||||
<div class="brand-fields-organization" style="display:none">
|
||||
<label>Контекст организации <textarea name="prompt_context" rows="5" placeholder="Наша компания, миссия, ключевые ценности, роль в отрасли..."></textarea></label>
|
||||
</div>
|
||||
<div class="brand-fields-community" style="display:none">
|
||||
<label>Контекст сообщества <textarea name="prompt_context" rows="5" placeholder="Миссия, ценности и форматы деятельности нашего сообщества..."></textarea></label>
|
||||
</div>
|
||||
<label>Tone of Voice <textarea name="prompt_tov" rows="4" placeholder="Мой голос — это..."></textarea></label>
|
||||
<label>Структура мысли <textarea name="prompt_structure" rows="4" placeholder="Зацепка → Тезис → Аргументация → Личный угол → Призыв..."></textarea></label>
|
||||
<label>Контент-направления <span class="form-hint">По строкам</span>
|
||||
<textarea name="prompt_directions" rows="4" placeholder="IT и бизнес... Предпринимательство... Развитие региона... Личностное развитие..."></textarea>
|
||||
</label>
|
||||
<label>Форматы и шаблоны <textarea name="prompt_formats" rows="5" placeholder="Экспертный пост... Личный пост... Вовлекающий пост..."></textarea></label>
|
||||
<label>Технические требования <textarea name="prompt_technical" rows="4" placeholder="Для Telegram: 3-7 абзацев..."></textarea></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Навигация -->
|
||||
<div class="survey-nav">
|
||||
<button type="button" id="btnPrev" onclick="prevStep()" hidden>← Назад</button>
|
||||
<span id="stepCounter"></span>
|
||||
<button type="button" id="btnNext" onclick="nextStep()">Далее →</button>
|
||||
<button type="submit" id="btnSubmit" hidden>💾 Сохранить и сгенерировать документ</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TOTAL_STEPS = 12;
|
||||
let currentStep = 1;
|
||||
|
||||
const FUNNEL_STAGES = [
|
||||
{name: "Этап 0: ОСВЕДОМЛЕННОСТЬ", content: "", cta: ""},
|
||||
{name: "Этап 1: ИНТЕРЕС", content: "", cta: ""},
|
||||
{name: "Этап 2: РАССМОТРЕНИЕ", content: "", cta: ""},
|
||||
{name: "Этап 3: РЕШЕНИЕ", content: "", cta: ""},
|
||||
{name: "КЛИЕНТ (Покупка)", content: "", cta: ""},
|
||||
];
|
||||
|
||||
// --- Переключение типа бренда ---
|
||||
function toggleBrandType() {
|
||||
var type = document.getElementById("brandType").value;
|
||||
var isPersonal = (type === "personal");
|
||||
var isOrg = (type === "organization");
|
||||
var isCommunity = (type === "community");
|
||||
|
||||
document.querySelectorAll(".brand-fields-personal").forEach(function(el) {
|
||||
el.style.display = isPersonal ? "" : "none";
|
||||
});
|
||||
document.querySelectorAll(".brand-fields-organization").forEach(function(el) {
|
||||
el.style.display = isOrg ? "" : "none";
|
||||
});
|
||||
document.querySelectorAll(".brand-fields-community").forEach(function(el) {
|
||||
el.style.display = isCommunity ? "" : "none";
|
||||
});
|
||||
document.getElementById("fieldsPersonal").style.display = isPersonal ? "" : "none";
|
||||
document.getElementById("fieldsOrganization").style.display = isOrg ? "" : "none";
|
||||
document.getElementById("fieldsCommunity").style.display = isCommunity ? "" : "none";
|
||||
|
||||
updatePillarHints(type);
|
||||
}
|
||||
|
||||
function updatePillarHints(type) {
|
||||
var p1 = document.querySelector(".pillar1-formats-hint");
|
||||
var p2 = document.querySelector(".pillar2-formats-hint");
|
||||
var p3 = document.querySelector(".pillar3-formats-hint");
|
||||
if (type === "community") {
|
||||
if (p1) p1.placeholder = "Миссия сообщества...\\nИстория создания...\\nЦенности и принципы...";
|
||||
if (p2) p2.placeholder = "Мероприятия и активности...\\nОбзоры встреч...\\nИтоги и анонсы...";
|
||||
if (p3) p3.placeholder = "Истории участников...\\nОтзывы и кейсы...\\nПриглашения к участию...";
|
||||
} else if (type === "organization") {
|
||||
if (p1) p1.placeholder = "Обзор продукта...\\nКейсы клиентов...\\nНовинки компании...";
|
||||
if (p2) p2.placeholder = "Экспертные статьи...\\nАналитика рынка...\\nРазборы технологий...";
|
||||
if (p3) p3.placeholder = "Опросы...\\nРозыгрыши...\\nОтзывы клиентов...";
|
||||
} else {
|
||||
if (p1) p1.placeholder = "История пути...\\nЗакулисье...\\nОбщественная деятельность...";
|
||||
if (p2) p2.placeholder = "Экспертные разборы...\\nПрактические советы...\\nКейсы...";
|
||||
if (p3) p3.placeholder = "Опросы...\\nПрямые эфиры...\\nИнтерактивные челленджи...";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Инициализация воронки ---
|
||||
function initFunnel() {
|
||||
const container = document.getElementById("funnelContainer");
|
||||
container.innerHTML = "";
|
||||
FUNNEL_STAGES.forEach((stage, i) => {
|
||||
container.innerHTML += `
|
||||
<div class="funnel-stage">
|
||||
<label><strong>${stage.name}</strong></label>
|
||||
<label>Контент <input type="text" name="funnel_${i}_content" placeholder="Какой контент на этом этапе"></label>
|
||||
<label>CTA <input type="text" name="funnel_${i}_cta" placeholder="Призыв к действию"></label>
|
||||
<input type="hidden" name="funnel_${i}_name" value="${stage.name}">
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Динамические каналы ---
|
||||
let channelIdx = 0;
|
||||
function addChannel(platform = "", type = "", frequency = "", description = "") {
|
||||
const tbody = document.getElementById("channelsBody");
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><input type="text" name="channels_${channelIdx}_platform" value="${platform}" placeholder="Напр. Telegram-канал"></td>
|
||||
<td><input type="text" name="channels_${channelIdx}_type" value="${type}" placeholder="Основной / доп."></td>
|
||||
<td><input type="text" name="channels_${channelIdx}_frequency" value="${frequency}" placeholder="Напр. 1 пост в день"></td>
|
||||
<td><input type="text" name="channels_${channelIdx}_description" value="${description}" placeholder="Описание"></td>
|
||||
<td><button type="button" class="button-link button-link-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||
tbody.appendChild(tr);
|
||||
channelIdx++;
|
||||
}
|
||||
|
||||
// --- Навигация ---
|
||||
function showStep(n) {
|
||||
document.querySelectorAll(".survey-step").forEach(el => el.hidden = true);
|
||||
const el = document.querySelector(`.survey-step[data-step="${n}"]`);
|
||||
if (el) el.hidden = false;
|
||||
document.getElementById("btnPrev").hidden = (n === 1);
|
||||
document.getElementById("btnNext").hidden = (n === TOTAL_STEPS);
|
||||
document.getElementById("btnSubmit").hidden = (n !== TOTAL_STEPS);
|
||||
document.getElementById("stepCounter").textContent = `Шаг ${n} из ${TOTAL_STEPS}`;
|
||||
// Прогресс
|
||||
const pct = ((n - 1) / (TOTAL_STEPS - 1)) * 100;
|
||||
document.getElementById("progressBar").style.width = pct + "%";
|
||||
// Индикатор
|
||||
const ind = document.getElementById("stepsIndicator");
|
||||
ind.innerHTML = "";
|
||||
for (let i = 1; i <= TOTAL_STEPS; i++) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "step-dot" + (i === n ? " active" : "") + (i < n ? " done" : "");
|
||||
dot.textContent = i;
|
||||
dot.onclick = () => showStep(i);
|
||||
ind.appendChild(dot);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (currentStep < TOTAL_STEPS) { currentStep++; showStep(currentStep); }
|
||||
}
|
||||
function prevStep() {
|
||||
if (currentStep > 1) { currentStep--; showStep(currentStep); }
|
||||
}
|
||||
|
||||
// --- Сборка данных ---
|
||||
function collectData() {
|
||||
const fd = new FormData(document.getElementById("surveyForm"));
|
||||
const data = {};
|
||||
for (const [key, val] of fd.entries()) {
|
||||
data[key] = val;
|
||||
}
|
||||
// Каналы
|
||||
const channels = [];
|
||||
for (let i = 0; i < channelIdx + 10; i++) {
|
||||
const p = data[`channels_${i}_platform`];
|
||||
if (p && p.trim()) {
|
||||
channels.push({
|
||||
platform: p,
|
||||
type: data[`channels_${i}_type`] || "",
|
||||
frequency: data[`channels_${i}_frequency`] || "",
|
||||
description: data[`channels_${i}_description`] || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
data["channels"] = channels;
|
||||
// Воронка
|
||||
const funnel = [];
|
||||
for (let i = 0; i < FUNNEL_STAGES.length; i++) {
|
||||
funnel.push({
|
||||
name: data[`funnel_${i}_name`] || FUNNEL_STAGES[i].name,
|
||||
content: data[`funnel_${i}_content`] || "",
|
||||
cta: data[`funnel_${i}_cta`] || "",
|
||||
});
|
||||
}
|
||||
data["funnel"] = funnel;
|
||||
return data;
|
||||
}
|
||||
|
||||
// --- Отправка ---
|
||||
document.getElementById("surveyForm").addEventListener("submit", async function(e) {
|
||||
e.preventDefault();
|
||||
const data = collectData();
|
||||
var brandType = data.brand_type || "personal";
|
||||
var title = (brandType === "organization")
|
||||
? (data.org_name || "Без названия")
|
||||
: (data.client_name || "Без названия");
|
||||
const btn = document.getElementById("btnSubmit");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "⏳ Генерация...";
|
||||
try {
|
||||
const resp = await fetch("/survey/submit", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({title: title, data: data}),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const result = await resp.json();
|
||||
window.location.href = "/surveys/" + result.id;
|
||||
} else {
|
||||
alert("Ошибка при сохранении: " + resp.status);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "💾 Сохранить и сгенерировать документ";
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Ошибка сети: " + err);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "💾 Сохранить и сгенерировать документ";
|
||||
}
|
||||
});
|
||||
|
||||
// --- Старт ---
|
||||
initFunnel();
|
||||
addChannel("Telegram-канал", "Основной", "1 пост в день", "Глубокий контент, экспертиза");
|
||||
addChannel("ВКвидео / Рутуб", "Доп.", "1 видео в неделю", "Длинные разборы, интервью");
|
||||
showStep(1);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Загрузить медиа-образ — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Загрузить медиа-образ</h1>
|
||||
|
||||
<div class="card">
|
||||
<p>Загрузите готовый медиа-образ файлом — он будет автоматически разобран и сохранён.</p>
|
||||
<p class="form-hint">Поддерживаются два формата:</p>
|
||||
<ul>
|
||||
<li><strong>.docx</strong> — документ стратегии (как образец). Разбирается через ИИ: извлекаются личность, УТП, Tone of Voice, столпы контента, архетипы и все поля опроса.</li>
|
||||
<li><strong>.json</strong> — структурированные данные опроса (прямой импорт полей).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/survey/import" enctype="multipart/form-data" class="form">
|
||||
<label>Файл медиа-образа
|
||||
<input type="file" name="file" accept=".docx,.json" required>
|
||||
</label>
|
||||
<button type="submit">📤 Загрузить</button>
|
||||
</form>
|
||||
|
||||
<div class="post-actions">
|
||||
<a href="/survey" class="copy-btn">➕ Заполнить форму вручную</a>
|
||||
<a href="/surveys" class="copy-btn">← К списку медиа-образов</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Медиа-образы — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Медиа-образы</h1>
|
||||
<div class="post-actions" style="margin-bottom:15px">
|
||||
<a class="tile" href="/survey">➕ Создать новый</a>
|
||||
<a class="tile" href="/survey/import">📤 Загрузить из файла</a>
|
||||
</div>
|
||||
|
||||
{% if surveys %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Название</th><th>Дата</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in surveys %}
|
||||
<tr>
|
||||
<td><a href="/surveys/{{ s.id }}">{{ s.title }}</a></td>
|
||||
<td>{{ s.created_at }}</td>
|
||||
<td>
|
||||
<a href="/surveys/{{ s.id }}/export" class="button-link">📄 .docx</a>
|
||||
<form action="/surveys/{{ s.id }}/delete" method="post" class="inline-form" onsubmit="return confirm('Удалить медиа-образ?')">
|
||||
<button type="submit" class="button-link button-link-danger">🗑</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<p>Пока нет ни одного медиа-образа. <a href="/survey">Создайте первый →</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,201 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ survey.title }} — Личный кабинет{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ survey.title }}</h1>
|
||||
<div class="post-actions">
|
||||
<a href="/surveys/{{ survey.id }}/export" class="copy-btn">📄 Скачать .docx</a>
|
||||
<a href="/survey" class="copy-btn">➕ Новый опрос</a>
|
||||
<a href="/surveys" class="copy-btn">← К списку</a>
|
||||
<form action="/surveys/{{ survey.id }}/delete" method="post" class="inline-form" onsubmit="return confirm('Удалить медиа-образ?')">
|
||||
<button type="submit" class="button-link button-link-danger">🗑 Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p><strong>Дата создания:</strong> {{ survey.created_at }}</p>
|
||||
</div>
|
||||
|
||||
{% set d = survey.data %}
|
||||
|
||||
<!-- 1. Резюме и цели -->
|
||||
<div class="card">
|
||||
<h2>1. Резюме и цели</h2>
|
||||
<p><strong>Клиент:</strong> {{ d.client_name }} ({{ d.client_role }}), {{ d.client_location }}</p>
|
||||
<p><strong>Текущая ситуация:</strong> {{ d.current_situation }}</p>
|
||||
<h3>Цели на 12 месяцев:</h3>
|
||||
<ul>
|
||||
<li><strong>Аудитория:</strong> {{ d.goal_audience_number }} подписчиков в {{ d.goal_audience_platform }} ({{ d.goal_audience_description }})</li>
|
||||
<li><strong>Экспертный статус:</strong> {{ d.goal_expert_field }}, {{ d.goal_expert_speaking }} выступлений/год</li>
|
||||
<li><strong>Коммерциализация:</strong> {{ d.goal_commercial_leads }} заявок/мес ({{ d.goal_commercial_services }})</li>
|
||||
<li><strong>Контент:</strong> {{ d.goal_content_system }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 2. Позиционирование -->
|
||||
<div class="card">
|
||||
<h2>2. Позиционирование и УТП</h2>
|
||||
<p><strong>Сущность бренда:</strong> {{ d.brand_essence }}</p>
|
||||
<p><strong>Кому помогаю:</strong> {{ d.positioning_who }}</p>
|
||||
<p><strong>Проблема:</strong> {{ d.positioning_problem }}</p>
|
||||
<p><strong>Результат:</strong> {{ d.positioning_result }}</p>
|
||||
<p><strong>УТП:</strong> {{ d.usp }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 3. Аудитории и каналы -->
|
||||
<div class="card">
|
||||
<h2>3. Аудитории и каналы</h2>
|
||||
<h3>Идеальные подписчики:</h3>
|
||||
<ul>
|
||||
{% for line in (d.ideal_audiences or '').split('\n') if line.strip() %}
|
||||
<li>{{ line.strip() }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if d.channels %}
|
||||
<h3>Каналы:</h3>
|
||||
<table class="table">
|
||||
<thead><tr><th>Платформа</th><th>Тип</th><th>Частота</th><th>Описание</th></tr></thead>
|
||||
<tbody>
|
||||
{% for ch in d.channels %}
|
||||
<tr><td>{{ ch.platform }}</td><td>{{ ch.type }}</td><td>{{ ch.frequency }}</td><td>{{ ch.description }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 4. Визуальная идентичность -->
|
||||
<div class="card">
|
||||
<h2>4. Визуальная идентичность</h2>
|
||||
<p><strong>Концепция:</strong> {{ d.visual_concept }}</p>
|
||||
<h3>Цвета:</h3>
|
||||
<ul>
|
||||
<li>Основной: {{ d.color_main_name }} ({{ d.color_main_hex }})</li>
|
||||
<li>Акцент 1: {{ d.color_accent1_name }} ({{ d.color_accent1_hex }})</li>
|
||||
<li>Акцент 2: {{ d.color_accent2_name }} ({{ d.color_accent2_hex }})</li>
|
||||
<li>Нейтральная база: {{ d.color_neutral }}</li>
|
||||
</ul>
|
||||
<h3>Типографика:</h3>
|
||||
<ul>
|
||||
<li>Заголовки: {{ d.typography_headings }}</li>
|
||||
<li>Текст: {{ d.typography_body }}</li>
|
||||
<li>Акценты: {{ d.typography_accents }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 5. ToV -->
|
||||
<div class="card">
|
||||
<h2>5. Tone of Voice</h2>
|
||||
<p><strong>Описание:</strong> {{ d.tov_description }}</p>
|
||||
<p><strong>Референсы:</strong> {{ d.tov_references }}</p>
|
||||
{% if d.tov_axes %}
|
||||
<h3>Оси ToV:</h3>
|
||||
<ul>
|
||||
{% for line in d.tov_axes.split('\n') if line.strip() %}
|
||||
<li>{{ line.strip() }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 6. Контент-стратегия -->
|
||||
<div class="card">
|
||||
<h2>6. Контент-стратегия: Три столпа</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th>Столп</th><th>Цель</th><th>Форматы</th><th>Частота</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>{{ d.pillar1_name }}</td><td>{{ d.pillar1_goal }}</td><td>{{ d.pillar1_formats }}</td><td>{{ d.pillar1_frequency }}</td></tr>
|
||||
<tr><td>{{ d.pillar2_name }}</td><td>{{ d.pillar2_goal }}</td><td>{{ d.pillar2_formats }}</td><td>{{ d.pillar2_frequency }}</td></tr>
|
||||
<tr><td>{{ d.pillar3_name }}</td><td>{{ d.pillar3_goal }}</td><td>{{ d.pillar3_formats }}</td><td>{{ d.pillar3_frequency }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if d.special_formats %}
|
||||
<p><strong>Особые форматы:</strong> {{ d.special_formats }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 7. Воронка и продукты -->
|
||||
<div class="card">
|
||||
<h2>7. Воронка и продуктовый ряд</h2>
|
||||
{% if d.funnel %}
|
||||
<table class="table">
|
||||
<thead><tr><th>Этап</th><th>Контент</th><th>CTA</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in d.funnel %}
|
||||
<tr><td>{{ s.name }}</td><td>{{ s.content }}</td><td>{{ s.cta }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<h3>Продуктовый ряд:</h3>
|
||||
<ul>
|
||||
<li><strong>Lead Magnet:</strong> {{ d.product_lead_magnet }}</li>
|
||||
<li><strong>Простая услуга:</strong> {{ d.product_simple }}</li>
|
||||
<li><strong>Услуга-конвертер:</strong> {{ d.product_converter }}</li>
|
||||
<li><strong>Комплексные решения:</strong> {{ d.product_complex }}</li>
|
||||
<li><strong>Экспертные продукты:</strong> {{ d.product_expert }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 8. Метрики -->
|
||||
<div class="card">
|
||||
<h2>8. Метрики</h2>
|
||||
{% if d.metrics_quantitative %}
|
||||
<h3>Количественные:</h3>
|
||||
<pre class="result">{{ d.metrics_quantitative }}</pre>
|
||||
{% endif %}
|
||||
{% if d.metrics_qualitative %}
|
||||
<h3>Качественные:</h3>
|
||||
<pre class="result">{{ d.metrics_qualitative }}</pre>
|
||||
{% endif %}
|
||||
{% if d.metrics_commercial %}
|
||||
<h3>Коммерческие:</h3>
|
||||
<pre class="result">{{ d.metrics_commercial }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 9. Риски -->
|
||||
<div class="card">
|
||||
<h2>9. Риски и рекомендации</h2>
|
||||
{% if d.risks %}
|
||||
<h3>Риски:</h3>
|
||||
<pre class="result">{{ d.risks }}</pre>
|
||||
{% endif %}
|
||||
{% if d.recommendations %}
|
||||
<h3>Рекомендации:</h3>
|
||||
<pre class="result">{{ d.recommendations }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 10. Архетипы -->
|
||||
<div class="card">
|
||||
<h2>10. Архетипический образ</h2>
|
||||
<p><strong>Ключевой архетип:</strong> {{ d.archetype_key_name }}</p>
|
||||
<p><strong>Суть:</strong> {{ d.archetype_key_essence }}</p>
|
||||
{% if d.archetype_key_manifestations %}
|
||||
<pre class="result">{{ d.archetype_key_manifestations }}</pre>
|
||||
{% endif %}
|
||||
{% if d.archetype_supporting %}
|
||||
<h3>Вспомогательные:</h3>
|
||||
<pre class="result">{{ d.archetype_supporting }}</pre>
|
||||
{% endif %}
|
||||
{% if d.archetype_synthesis %}
|
||||
<h3>Синтез:</h3>
|
||||
<p>{{ d.archetype_synthesis }}</p>
|
||||
{% endif %}
|
||||
{% if d.archetype_table %}
|
||||
<h3>Таблица проявлений:</h3>
|
||||
<pre class="result">{{ d.archetype_table }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 11. Промпт -->
|
||||
<div class="card">
|
||||
<h2>11. Промпт для генерации контента</h2>
|
||||
{% if d.prompt_context %}<p><strong>Контекст:</strong> {{ d.prompt_context }}</p>{% endif %}
|
||||
{% if d.prompt_tov %}<p><strong>ToV:</strong> {{ d.prompt_tov }}</p>{% endif %}
|
||||
{% if d.prompt_structure %}<p><strong>Структура:</strong> {{ d.prompt_structure }}</p>{% endif %}
|
||||
{% if d.prompt_directions %}<p><strong>Направления:</strong></p><pre class="result">{{ d.prompt_directions }}</pre>{% endif %}
|
||||
{% if d.prompt_formats %}<p><strong>Форматы:</strong></p><pre class="result">{{ d.prompt_formats }}</pre>{% endif %}
|
||||
{% if d.prompt_technical %}<p><strong>Тех. требования:</strong></p><pre class="result">{{ d.prompt_technical }}</pre>{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
+527
-2
@@ -80,6 +80,113 @@ def create_db():
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS brand_surveys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
title TEXT,
|
||||
data TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
chat_id TEXT,
|
||||
title TEXT,
|
||||
username TEXT,
|
||||
chat_type TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS provider_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER UNIQUE,
|
||||
text_provider_type TEXT DEFAULT 'piapi',
|
||||
text_api_key TEXT,
|
||||
text_base_url TEXT,
|
||||
text_model TEXT,
|
||||
image_provider_type TEXT DEFAULT 'piapi',
|
||||
image_api_key TEXT,
|
||||
image_base_url TEXT,
|
||||
image_model TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS post_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER,
|
||||
user_id INTEGER,
|
||||
filename TEXT,
|
||||
source TEXT DEFAULT 'upload',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
result_json TEXT,
|
||||
error TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS post_revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# Миграция со старой схемы (provider_type/api_key/base_url/text_model/image_model)
|
||||
# на новую (text_* + image_*). Если старых колонок нет — пропускаем.
|
||||
cursor.execute("PRAGMA table_info(provider_settings)")
|
||||
ps_columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'provider_type' in ps_columns and 'text_provider_type' in ps_columns:
|
||||
# Промежуточное состояние миграции — копируем данные из старых колонок в новые
|
||||
try:
|
||||
cursor.execute('''
|
||||
UPDATE provider_settings SET
|
||||
text_provider_type = COALESCE(text_provider_type, provider_type),
|
||||
text_api_key = COALESCE(text_api_key, api_key),
|
||||
text_base_url = COALESCE(text_base_url, base_url),
|
||||
image_provider_type = COALESCE(image_provider_type, provider_type),
|
||||
image_api_key = COALESCE(image_api_key, api_key),
|
||||
image_base_url = COALESCE(image_base_url, base_url),
|
||||
image_model = COALESCE(image_model, image_model)
|
||||
WHERE api_key IS NOT NULL
|
||||
''')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Колонка published в posts
|
||||
cursor.execute("PRAGMA table_info(posts)")
|
||||
posts_columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'published' not in posts_columns:
|
||||
cursor.execute('ALTER TABLE posts ADD COLUMN published BOOLEAN DEFAULT 0')
|
||||
print("✅ Колонка 'published' добавлена в таблицу posts")
|
||||
if 'published_at' not in posts_columns:
|
||||
cursor.execute('ALTER TABLE posts ADD COLUMN published_at TIMESTAMP')
|
||||
print("✅ Колонка 'published_at' добавлена в таблицу posts")
|
||||
if 'published_to' not in posts_columns:
|
||||
cursor.execute('ALTER TABLE posts ADD COLUMN published_to TEXT')
|
||||
print("✅ Колонка 'published_to' добавлена в таблицу posts")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("✅ База данных создана!")
|
||||
@@ -209,8 +316,10 @@ def get_user_by_id(user_id):
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def create_web_user(email, password_hash, name, phone, business_type, description):
|
||||
"""Создаёт пользователя веб-кабинета (без telegram_id). Возвращает id нового пользователя."""
|
||||
def create_web_user(email, password_hash, name, phone, business_type=None, description=None):
|
||||
"""Создаёт пользователя веб-кабинета (без telegram_id).
|
||||
business_type и description опциональны — посты генерируются на основе медиа-образа.
|
||||
Возвращает id нового пользователя."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
@@ -253,7 +362,15 @@ def save_post_for_user(user_id, platform, content, style=None, wishes=None):
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (user_id, platform, content, style, wishes))
|
||||
conn.commit()
|
||||
post_id = cursor.lastrowid
|
||||
# Сохраняем начальную ревизию
|
||||
cursor.execute(
|
||||
'INSERT INTO post_revisions (post_id, user_id, content) VALUES (?, ?, ?)',
|
||||
(post_id, user_id, content),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return post_id
|
||||
|
||||
|
||||
def get_posts_for_user(user_id, limit=10):
|
||||
@@ -327,6 +444,35 @@ def delete_post_by_id(user_id, post_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
# === РЕВИЗИИ ПОСТОВ ===
|
||||
|
||||
def add_post_revision(post_id, user_id, content):
|
||||
"""Сохраняет ревизию (каждое изменение текста поста)."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'INSERT INTO post_revisions (post_id, user_id, content) VALUES (?, ?, ?)',
|
||||
(post_id, user_id, content),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_post_revisions(post_id, user_id, limit=50):
|
||||
"""Возвращает последние N ревизий поста (от новых к старым)."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT id, content, created_at FROM post_revisions '
|
||||
'WHERE post_id = ? AND user_id = ? ORDER BY id DESC LIMIT ?',
|
||||
(post_id, user_id, limit),
|
||||
)
|
||||
rows = [dict(r) for r in cursor.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
# === КОНТЕНТ-ПЛАН (веб-кабинет) ===
|
||||
|
||||
PLAN_STATUSES = ('planned', 'published', 'postponed')
|
||||
@@ -438,5 +584,384 @@ def delete_media_by_id(user_id, media_id):
|
||||
return None
|
||||
|
||||
|
||||
# === ОПРОСЫ МЕДИА-ОБРАЗА (веб-кабинет) ===
|
||||
|
||||
import json as _json
|
||||
|
||||
|
||||
def save_brand_survey(user_id, title, data):
|
||||
"""Сохраняет опрос медиа-образа. data — dict (сериализуется в JSON).
|
||||
Возвращает id новой записи."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'INSERT INTO brand_surveys (user_id, title, data) VALUES (?, ?, ?)',
|
||||
(user_id, title, _json.dumps(data, ensure_ascii=False))
|
||||
)
|
||||
conn.commit()
|
||||
survey_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return survey_id
|
||||
|
||||
|
||||
def get_brand_surveys_for_user(user_id, limit=50):
|
||||
"""Возвращает список опросов пользователя (без поля data)."""
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT id, title, created_at FROM brand_surveys '
|
||||
'WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
|
||||
(user_id, limit)
|
||||
)
|
||||
rows = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def get_latest_brand_survey_for_user(user_id):
|
||||
"""Возвращает последний опрос медиа-образа пользователя (с data) или None."""
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT * FROM brand_surveys WHERE user_id = ? ORDER BY created_at DESC LIMIT 1',
|
||||
(user_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
result = dict(row)
|
||||
result['data'] = _json.loads(result['data']) if result['data'] else {}
|
||||
return result
|
||||
|
||||
|
||||
def get_brand_survey_by_id(survey_id, user_id):
|
||||
"""Возвращает один опрос (с data) только если он принадлежит user_id."""
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT * FROM brand_surveys WHERE id = ? AND user_id = ?',
|
||||
(survey_id, user_id)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
result = dict(row)
|
||||
result['data'] = _json.loads(result['data']) if result['data'] else {}
|
||||
return result
|
||||
|
||||
|
||||
def delete_brand_survey(user_id, survey_id):
|
||||
"""Удаляет опрос. Только если он принадлежит user_id."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'DELETE FROM brand_surveys WHERE id = ? AND user_id = ?',
|
||||
(survey_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# === КАНАЛЫ ДЛЯ ПУБЛИКАЦИИ (веб-кабинет) ===
|
||||
|
||||
def save_channel(user_id, chat_id, title, username, chat_type):
|
||||
"""Сохраняет канал/чат для публикации. Возвращает id записи."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'INSERT INTO channels (user_id, chat_id, title, username, chat_type) VALUES (?, ?, ?, ?, ?)',
|
||||
(user_id, str(chat_id), title, username, chat_type)
|
||||
)
|
||||
conn.commit()
|
||||
channel_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return channel_id
|
||||
|
||||
|
||||
def get_channels_for_user(user_id):
|
||||
"""Возвращает список каналов пользователя."""
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT id, chat_id, title, username, chat_type, created_at '
|
||||
'FROM channels WHERE user_id = ? ORDER BY created_at DESC',
|
||||
(user_id,)
|
||||
)
|
||||
rows = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def get_channel_by_id(channel_id, user_id):
|
||||
"""Возвращает один канал только если он принадлежит user_id."""
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT * FROM channels WHERE id = ? AND user_id = ?',
|
||||
(channel_id, user_id)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def delete_channel(user_id, channel_id):
|
||||
"""Удаляет канал. Только если он принадлежит user_id."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'DELETE FROM channels WHERE id = ? AND user_id = ?',
|
||||
(channel_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_channel_already_added(user_id, chat_id):
|
||||
"""Проверяет, добавлен ли уже канал с таким chat_id."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT id FROM channels WHERE user_id = ? AND chat_id = ?',
|
||||
(user_id, str(chat_id))
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return row is not None
|
||||
|
||||
|
||||
# === ОТМЕТКА О ПУБЛИКАЦИИ ПОСТА ===
|
||||
|
||||
def mark_post_published(post_id, user_id, channel_title):
|
||||
"""Отмечает пост как опубликованный."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE posts SET published = 1, published_at = CURRENT_TIMESTAMP, '
|
||||
'published_to = ? WHERE id = ? AND user_id = ?',
|
||||
(channel_title, post_id, user_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# === НАСТРОЙКИ ПРОВАЙДЕРА ИИ (веб-кабинет) ===
|
||||
|
||||
# Пресеты провайдеров
|
||||
PROVIDER_PRESETS = {
|
||||
'piapi': {
|
||||
'name': 'PiAPI',
|
||||
'base_url': 'https://api.piapi.ai/v1',
|
||||
'text_model': 'gpt-4o',
|
||||
'image_model': 'gpt-image-1',
|
||||
'needs_key': True,
|
||||
'supports_images': True,
|
||||
},
|
||||
'openrouter': {
|
||||
'name': 'OpenRouter',
|
||||
'base_url': 'https://openrouter.ai/api/v1',
|
||||
'text_model': 'openai/gpt-4o',
|
||||
'image_model': 'openai/dall-e-3',
|
||||
'needs_key': True,
|
||||
'supports_images': True,
|
||||
},
|
||||
'opencode': {
|
||||
'name': 'OpenCode Zen',
|
||||
'base_url': 'https://opencode.ai/zen/v1',
|
||||
'text_model': 'nemotron-3-ultra-free',
|
||||
'image_model': '',
|
||||
'needs_key': True,
|
||||
'supports_images': False,
|
||||
},
|
||||
'openai': {
|
||||
'name': 'OpenAI',
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'text_model': 'gpt-4o',
|
||||
'image_model': 'gpt-image-1',
|
||||
'needs_key': True,
|
||||
'supports_images': True,
|
||||
},
|
||||
'custom': {
|
||||
'name': 'Кастомный (OpenAI-совместимый)',
|
||||
'base_url': '',
|
||||
'text_model': '',
|
||||
'image_model': '',
|
||||
'needs_key': True,
|
||||
'supports_images': True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_provider_settings(user_id):
|
||||
"""Возвращает настройки провайдеров пользователя (text + image) или None."""
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT text_provider_type, text_api_key, text_base_url, text_model,
|
||||
image_provider_type, image_api_key, image_base_url, image_model,
|
||||
updated_at
|
||||
FROM provider_settings WHERE user_id = ?
|
||||
''', (user_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None
|
||||
d = dict(row)
|
||||
return {
|
||||
# Text provider
|
||||
'text_provider_type': d['text_provider_type'],
|
||||
'text_api_key': d['text_api_key'],
|
||||
'text_base_url': d['text_base_url'],
|
||||
'text_model': d['text_model'],
|
||||
# Image provider
|
||||
'image_provider_type': d['image_provider_type'],
|
||||
'image_api_key': d['image_api_key'],
|
||||
'image_base_url': d['image_base_url'],
|
||||
'image_model': d['image_model'],
|
||||
# Meta
|
||||
'updated_at': d['updated_at'],
|
||||
}
|
||||
|
||||
|
||||
def get_text_provider_settings(user_id):
|
||||
"""Возвращает настройки только text-провайдера в формате для ContentGenerator."""
|
||||
s = get_provider_settings(user_id)
|
||||
if not s or not s.get('text_api_key'):
|
||||
return None
|
||||
return {
|
||||
'provider_type': s['text_provider_type'],
|
||||
'api_key': s['text_api_key'],
|
||||
'base_url': s['text_base_url'],
|
||||
'text_model': s['text_model'],
|
||||
}
|
||||
|
||||
|
||||
def get_image_provider_settings(user_id):
|
||||
"""Возвращает настройки только image-провайдера в формате для ImageGenerator."""
|
||||
s = get_provider_settings(user_id)
|
||||
if not s or not s.get('image_api_key'):
|
||||
return None
|
||||
return {
|
||||
'provider_type': s['image_provider_type'],
|
||||
'api_key': s['image_api_key'],
|
||||
'base_url': s['image_base_url'],
|
||||
'image_model': s['image_model'],
|
||||
}
|
||||
|
||||
|
||||
def save_provider_settings(user_id, text_provider_type, text_api_key, text_base_url, text_model,
|
||||
image_provider_type, image_api_key, image_base_url, image_model):
|
||||
"""Сохраняет или обновляет настройки обоих провайдеров."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO provider_settings (
|
||||
user_id, text_provider_type, text_api_key, text_base_url, text_model,
|
||||
image_provider_type, image_api_key, image_base_url, image_model, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
text_provider_type = excluded.text_provider_type,
|
||||
text_api_key = excluded.text_api_key,
|
||||
text_base_url = excluded.text_base_url,
|
||||
text_model = excluded.text_model,
|
||||
image_provider_type = excluded.image_provider_type,
|
||||
image_api_key = excluded.image_api_key,
|
||||
image_base_url = excluded.image_base_url,
|
||||
image_model = excluded.image_model,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
''', (user_id, text_provider_type, text_api_key, text_base_url, text_model,
|
||||
image_provider_type, image_api_key, image_base_url, image_model))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# === ИЗОБРАЖЕНИЯ ПОСТОВ (веб-кабинет) ===
|
||||
|
||||
def save_post_image(post_id, user_id, filename, source='upload'):
|
||||
"""Сохраняет запись об изображении, прикреплённом к посту."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'INSERT INTO post_images (post_id, user_id, filename, source) VALUES (?, ?, ?, ?)',
|
||||
(post_id, user_id, filename, source)
|
||||
)
|
||||
conn.commit()
|
||||
img_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return img_id
|
||||
|
||||
|
||||
def get_post_images(post_id, user_id):
|
||||
"""Возвращает все изображения поста (только если пост принадлежит user_id)."""
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT id, filename, source, created_at FROM post_images '
|
||||
'WHERE post_id = ? AND user_id = ? ORDER BY created_at ASC',
|
||||
(post_id, user_id)
|
||||
)
|
||||
rows = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return rows
|
||||
|
||||
|
||||
def delete_post_image(image_id, user_id):
|
||||
"""Удаляет изображение. Возвращает filename для удаления файла, или None."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'SELECT filename FROM post_images WHERE id = ? AND user_id = ?',
|
||||
(image_id, user_id)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
cursor.execute('DELETE FROM post_images WHERE id = ? AND user_id = ?', (image_id, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return row[0]
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
|
||||
# === Задачи (background tasks) ===
|
||||
|
||||
def create_task(user_id, task_type):
|
||||
"""Создаёт задачу. Возвращает task_id."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'INSERT INTO tasks (user_id, task_type, status) VALUES (?, ?, ?)',
|
||||
(user_id, task_type, 'pending')
|
||||
)
|
||||
conn.commit()
|
||||
task_id = cursor.lastrowid
|
||||
conn.close()
|
||||
return task_id
|
||||
|
||||
|
||||
def update_task_status(task_id, status, result_json=None, error=None):
|
||||
"""Обновляет статус задачи."""
|
||||
conn = sqlite3.connect('users.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
'UPDATE tasks SET status = ?, result_json = ?, error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
(status, result_json, error, task_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_task(task_id):
|
||||
"""Возвращает задачу по ID."""
|
||||
conn = _row_conn()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM tasks WHERE id = ?', (task_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# Создаем базу при первом импорте
|
||||
create_db()
|
||||
@@ -0,0 +1,57 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# База данных
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Медиа файлы
|
||||
media/
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
*.gif
|
||||
*.webp
|
||||
*.pdf
|
||||
|
||||
# Логи
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Переменные окружения
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Секреты
|
||||
secrets/
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# Деплой
|
||||
deploy.sh
|
||||
nginx-setup.sh
|
||||
ssl-setup.sh
|
||||
dev.sh
|
||||
README.md
|
||||
@@ -0,0 +1,212 @@
|
||||
# 🚀 ИИ Пресс-секретарь — Скрипты деплоя
|
||||
|
||||
## 📁 Файлы
|
||||
|
||||
| Скрипт | Назначение |
|
||||
|--------|-----------|
|
||||
| `install.sh` | Полная установка (все в одном) |
|
||||
| `deploy.sh` | Деплой приложения |
|
||||
| `nginx-setup.sh` | Настройка Nginx |
|
||||
| `ssl-setup.sh` | SSL сертификат (Let's Encrypt) |
|
||||
| `backup.sh` | Бэкап проекта |
|
||||
| `restore.sh` | Восстановление из бэкапа |
|
||||
| `dev.sh` | Быстрый запуск в development |
|
||||
|
||||
## 🎯 Быстрая установка (3 шага)
|
||||
|
||||
### 1. Скопируй файлы на сервер
|
||||
|
||||
```bash
|
||||
scp -r deploy.sh nginx-setup.sh ssl-setup.sh backup.sh restore.sh dev.sh README.md user@server:/opt/pressa/
|
||||
```
|
||||
|
||||
### 2. Запусти установку
|
||||
|
||||
```bash
|
||||
cd /opt/pressa
|
||||
chmod +x *.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### 3. Настрой .env
|
||||
|
||||
```bash
|
||||
nano /opt/pressa/.env
|
||||
```
|
||||
|
||||
Заполни переменные:
|
||||
```env
|
||||
BOT_TOKEN=твой_токен_бота
|
||||
PIAPI_API_KEY=твой_piapi_ключ
|
||||
PIAPI_MODEL=gpt-4o
|
||||
PROXY_URL=http://user:pass@host:port
|
||||
CABINET_URL=https://твой_домен.com
|
||||
```
|
||||
|
||||
## 📖 Использование отдельных скриптов
|
||||
|
||||
### Деплой
|
||||
|
||||
```bash
|
||||
cd /opt/pressa
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```bash
|
||||
./nginx-setup.sh
|
||||
```
|
||||
|
||||
Замените `твой_домен.com` на свой домен.
|
||||
|
||||
### SSL
|
||||
|
||||
```bash
|
||||
./ssl-setup.sh
|
||||
```
|
||||
|
||||
### Бэкап
|
||||
|
||||
```bash
|
||||
./backup.sh
|
||||
```
|
||||
|
||||
### Восстановление
|
||||
|
||||
```bash
|
||||
./restore.sh
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
## 🔧 Управление сервисом
|
||||
|
||||
```bash
|
||||
# Статус
|
||||
sudo systemctl status pressa
|
||||
|
||||
# Перезапуск
|
||||
sudo systemctl restart pressa
|
||||
|
||||
# Остановка
|
||||
sudo systemctl stop pressa
|
||||
|
||||
# Запуск
|
||||
sudo systemctl start pressa
|
||||
|
||||
# Логи
|
||||
sudo journalctl -u pressa -f
|
||||
|
||||
# Логи Nginx
|
||||
sudo tail -f /var/log/nginx/pressa-error.log
|
||||
```
|
||||
|
||||
## 🔄 Обновление
|
||||
|
||||
```bash
|
||||
cd /opt/pressa
|
||||
git pull origin main
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
```bash
|
||||
# Приложение
|
||||
sudo journalctl -u pressa -f
|
||||
|
||||
# Nginx
|
||||
sudo tail -f /var/log/nginx/pressa-error.log
|
||||
sudo tail -f /var/log/nginx/pressa-access.log
|
||||
|
||||
# Бэкапы
|
||||
ls -lh /opt/backups/
|
||||
```
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
### Сервис не запускается
|
||||
|
||||
```bash
|
||||
sudo systemctl status pressa
|
||||
sudo journalctl -u pressa -n 50
|
||||
```
|
||||
|
||||
### Nginx не работает
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
### 500 ошибка
|
||||
|
||||
```bash
|
||||
sudo journalctl -u pressa -n 100
|
||||
sudo tail -f /var/log/nginx/pressa-error.log
|
||||
```
|
||||
|
||||
### Проблемы с зависимостями
|
||||
|
||||
```bash
|
||||
cd /opt/pressa
|
||||
source venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 📦 Структура
|
||||
|
||||
```
|
||||
/opt/pressa/
|
||||
├── cabinet_service/ # Основной код
|
||||
│ ├── app.py # FastAPI
|
||||
│ ├── templates/ # HTML
|
||||
│ ├── static/ # CSS/JS
|
||||
│ ├── media/ # Файлы
|
||||
│ └── users.db # База данных
|
||||
├── ai_core/ # AI модули
|
||||
├── venv/ # Python venv
|
||||
├── .env # Переменные
|
||||
├── requirements.txt # Зависимости
|
||||
├── deploy/ # Скрипты деплоя
|
||||
│ ├── install.sh
|
||||
│ ├── deploy.sh
|
||||
│ ├── nginx-setup.sh
|
||||
│ ├── ssl-setup.sh
|
||||
│ ├── backup.sh
|
||||
│ ├── restore.sh
|
||||
│ └── dev.sh
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🎓 Полезные команды
|
||||
|
||||
```bash
|
||||
# Проверка порта
|
||||
sudo netstat -tlnp | grep 8001
|
||||
|
||||
# Проверка RAM
|
||||
free -h
|
||||
|
||||
# Проверка диска
|
||||
df -h
|
||||
|
||||
# Перезагрузка
|
||||
sudo reboot
|
||||
|
||||
# Остановка Nginx
|
||||
sudo systemctl stop nginx
|
||||
|
||||
# Запуск Nginx
|
||||
sudo systemctl start nginx
|
||||
```
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
Если что-то не работает — проверяй логи и убедись, что все переменные в `.env` заполнены.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Деплой с Windows на сервер
|
||||
|
||||
## ⚡ Быстрый старт
|
||||
|
||||
1. **Открой PowerShell** (от имени администратора не обязательно)
|
||||
|
||||
2. **Перейди в папку проекта:**
|
||||
```powershell
|
||||
cd C:\python\pressa\deploy
|
||||
```
|
||||
|
||||
3. **Запусти деплой:**
|
||||
```powershell
|
||||
.\deploy.ps1
|
||||
```
|
||||
|
||||
## 📋 Что делает скрипт
|
||||
|
||||
1. ✅ Архивирует проект (исключая venv, __pycache__)
|
||||
2. ✅ Подключается к серверу по SSH
|
||||
3. ✅ Загружает файлы через SCP
|
||||
4. ✅ Распаковывает на сервере
|
||||
5. ✅ Запускает `install.sh` (установка Python, Nginx, SSL)
|
||||
6. ✅ Настраивает домен `press.my-dpr.ru`
|
||||
7. ✅ Получает SSL сертификат
|
||||
|
||||
## ⏱️ Время выполнения
|
||||
|
||||
- Загрузка: 1-2 минуты
|
||||
- Установка на сервере: 5-10 минут
|
||||
- **Итого: ~10 минут**
|
||||
|
||||
## 🔧 Параметры (опционально)
|
||||
|
||||
Если нужно изменить настройки:
|
||||
|
||||
```powershell
|
||||
.\deploy.ps1 -ServerIP "31.28.9.200" -Domain "press.my-dpr.ru"
|
||||
```
|
||||
|
||||
Доступные параметры:
|
||||
- `-ServerIP` — IP сервера
|
||||
- `-Username` — логин (по умолчанию root)
|
||||
- `-Password` — пароль
|
||||
- `-Domain` — домен
|
||||
- `-RemotePath` — путь на сервере (по умолчанию /opt/pressa)
|
||||
- `-LocalProjectPath` — путь к проекту на компьютере
|
||||
|
||||
## 🆘 Если не работает
|
||||
|
||||
### Ошибка "SSH не найден"
|
||||
|
||||
Установи OpenSSH Client:
|
||||
1. Параметры → Приложения → Дополнительные компоненты
|
||||
2. Добавить компонент → OpenSSH Client
|
||||
3. Перезагрузи PowerShell
|
||||
|
||||
### Ошибка подключения
|
||||
|
||||
Проверь:
|
||||
```powershell
|
||||
# Пинг сервера
|
||||
ping 31.28.9.200
|
||||
|
||||
# Проверка SSH
|
||||
ssh root@31.28.9.200
|
||||
```
|
||||
|
||||
### Ручной деплой (если скрипт не сработал)
|
||||
|
||||
1. Запакуй проект в zip
|
||||
2. Загрузи через FileZilla / WinSCP на сервер
|
||||
3. Подключись по SSH через PuTTY
|
||||
4. Выполни команды из `install.sh` вручную
|
||||
|
||||
## ✅ После деплоя
|
||||
|
||||
1. **Настрой .env файл:**
|
||||
```bash
|
||||
ssh root@31.28.9.200
|
||||
nano /opt/pressa/.env
|
||||
```
|
||||
|
||||
Заполни:
|
||||
```env
|
||||
BOT_TOKEN=твой_токен_бота
|
||||
PIAPI_API_KEY=твой_piapi_ключ
|
||||
PROXY_URL=http://user:pass@host:port
|
||||
CABINET_URL=https://press.my-dpr.ru
|
||||
```
|
||||
|
||||
2. **Перезапусти сервис:**
|
||||
```bash
|
||||
systemctl restart pressa
|
||||
```
|
||||
|
||||
3. **Проверь статус:**
|
||||
```bash
|
||||
systemctl status pressa
|
||||
journalctl -u pressa -f
|
||||
```
|
||||
|
||||
## 🌐 Доступ
|
||||
|
||||
После успешного деплоя:
|
||||
- **Сайт:** https://press.my-dpr.ru
|
||||
- **API:** https://press.my-dpr.ru/api/
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
Если что-то пошло не так — проверь логи:
|
||||
```bash
|
||||
# На сервере
|
||||
journalctl -u pressa -n 50
|
||||
tail -f /var/log/nginx/pressa-error.log
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
|
||||
# === Бэкап проекта ===
|
||||
|
||||
set -e
|
||||
|
||||
APP_DIR="/opt/pressa"
|
||||
BACKUP_DIR="/opt/backups"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_NAME="pressa_$DATE.tar.gz"
|
||||
|
||||
echo "=== Создание бэкапа $BACKUP_NAME ==="
|
||||
|
||||
# Создание папки для бэкапов
|
||||
mkdir -p $BACKUP_DIR
|
||||
|
||||
# Создание архива
|
||||
cd $APP_DIR
|
||||
tar -czf $BACKUP_DIR/$BACKUP_NAME \
|
||||
cabinet_service/users.db \
|
||||
cabinet_service/media \
|
||||
--exclude='cabinet_service/media/*' \
|
||||
--exclude='venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc'
|
||||
|
||||
# Удаление старых бэкапов (сохранять последние 7)
|
||||
find $BACKUP_DIR -name "pressa_*.tar.gz" -mtime +7 -delete
|
||||
|
||||
echo "✓ Бэкап создан: $BACKUP_DIR/$BACKUP_NAME"
|
||||
echo "Размер: $(du -h $BACKUP_DIR/$BACKUP_NAME | cut -f1)"
|
||||
echo ""
|
||||
echo "Список бэкапов:"
|
||||
ls -lh $BACKUP_DIR/pressa_*.tar.gz | tail -5
|
||||
|
||||
# Опционально: копировать на другой сервер
|
||||
# scp $BACKUP_DIR/$BACKUP_NAME user@backup-server:/backups/
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env pwsh
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deploy AI Press Secretary to server from Windows computer
|
||||
.DESCRIPTION
|
||||
1. Archives project
|
||||
2. Uploads to server via SCP
|
||||
3. Connects via SSH and runs installation
|
||||
4. Configures Nginx + SSL
|
||||
.NOTES
|
||||
Run: .\deploy-simple.ps1
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerIP = "31.28.9.200",
|
||||
[string]$Username = "root",
|
||||
[string]$Password = "123edcxzaqws",
|
||||
[string]$Domain = "press.my-dpr.ru",
|
||||
[string]$RemotePath = "/opt/pressa",
|
||||
[string]$LocalProjectPath = "C:\python\pressa",
|
||||
[int]$Port = 2324
|
||||
)
|
||||
|
||||
Write-Host "=== AI Press Secretary Deployment ===" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# Check SSH/SCP
|
||||
Write-Host "[1/6] Checking SSH and SCP..." -ForegroundColor Yellow
|
||||
$ssh = Get-Command ssh -ErrorAction SilentlyContinue
|
||||
$scp = Get-Command scp -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $ssh -or -not $scp) {
|
||||
Write-Host "ERROR: SSH or SCP not found!" -ForegroundColor Red
|
||||
Write-Host "Install OpenSSH Client: Settings -> Apps -> Optional Features -> OpenSSH Client"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "OK" -ForegroundColor Green
|
||||
|
||||
# Create archive
|
||||
Write-Host "[2/6] Creating project archive..." -ForegroundColor Yellow
|
||||
$TempDir = "$env:TEMP\pressa-deploy-$(Get-Random)"
|
||||
$ArchiveName = "pressa-deploy.tar.gz"
|
||||
$ArchivePath = "$env:TEMP\$ArchiveName"
|
||||
|
||||
if (Test-Path $TempDir) { Remove-Item $TempDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $TempDir | Out-Null
|
||||
|
||||
$ItemsToCopy = @("cabinet_service", "ai_core", "config.py", "requirements.txt", "deploy")
|
||||
foreach ($item in $ItemsToCopy) {
|
||||
$src = Join-Path $LocalProjectPath $item
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Path $src -Destination $TempDir -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Update domain in scripts
|
||||
$nginxScript = Join-Path $TempDir "deploy\nginx-setup.sh"
|
||||
if (Test-Path $nginxScript) {
|
||||
$content = [System.IO.File]::ReadAllText($nginxScript)
|
||||
$content = $content.Replace("твой_домен.com", $Domain)
|
||||
[System.IO.File]::WriteAllText($nginxScript, $content)
|
||||
}
|
||||
|
||||
$sslScript = Join-Path $TempDir "deploy\ssl-setup.sh"
|
||||
if (Test-Path $sslScript) {
|
||||
$content = [System.IO.File]::ReadAllText($sslScript)
|
||||
$content = $content.Replace("твой_домен.com", $Domain)
|
||||
$content = $content.Replace("твой_email@example.com", "admin@$Domain")
|
||||
[System.IO.File]::WriteAllText($sslScript, $content)
|
||||
}
|
||||
|
||||
# Create archive
|
||||
Set-Location $env:TEMP
|
||||
$tar = Get-Command tar -ErrorAction SilentlyContinue
|
||||
if ($tar) {
|
||||
tar -czf $ArchiveName -C $TempDir .
|
||||
} else {
|
||||
Compress-Archive -Path "$TempDir\*" -DestinationPath "$env:TEMP\pressa-deploy.zip" -Force
|
||||
$ArchiveName = "pressa-deploy.zip"
|
||||
$ArchivePath = "$env:TEMP\$ArchiveName"
|
||||
}
|
||||
|
||||
$size = [math]::Round((Get-Item $ArchivePath).Length / 1MB, 1)
|
||||
Write-Host "Archive created: $ArchivePath ($size MB)" -ForegroundColor Green
|
||||
|
||||
# Upload to server
|
||||
Write-Host "[3/6] Uploading to server ${ServerIP}:${Port}..." -ForegroundColor Yellow
|
||||
$sshOptions = "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $Port"
|
||||
|
||||
# Create directory
|
||||
$createDir = "mkdir -p $RemotePath && rm -rf $RemotePath/*"
|
||||
$createDir | ssh $sshOptions ${Username}@${ServerIP} "bash -s" 2>$null
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "ERROR: Cannot connect to server!" -ForegroundColor Red
|
||||
Write-Host "Check: IP address, login, password, port $Port" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Connected" -ForegroundColor Green
|
||||
|
||||
# Upload archive
|
||||
scp $sshOptions $ArchivePath "${Username}@${ServerIP}:${RemotePath}/" 2>$null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "ERROR: Upload failed!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Uploaded" -ForegroundColor Green
|
||||
|
||||
# Extract and install
|
||||
Write-Host "[4/6] Installing on server (this takes 5-10 minutes)..." -ForegroundColor Yellow
|
||||
$installScript = @"
|
||||
cd $RemotePath
|
||||
|
||||
# Extract
|
||||
if [ -f "$ArchiveName" ]; then
|
||||
tar -xzf "$ArchiveName"
|
||||
rm "$ArchiveName"
|
||||
elif [ -f "pressa-deploy.zip" ]; then
|
||||
apt-get install -y unzip >/dev/null 2>&1
|
||||
unzip -q "pressa-deploy.zip"
|
||||
rm "pressa-deploy.zip"
|
||||
fi
|
||||
|
||||
# Fix directory structure
|
||||
if [ ! -d "cabinet_service" ]; then
|
||||
for dir in */; do
|
||||
if [ -d "\$dir/cabinet_service" ]; then
|
||||
mv "\$dir"/* .
|
||||
rm -rf "\$dir"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Make scripts executable
|
||||
chmod +x deploy/*.sh
|
||||
|
||||
# Run install
|
||||
cd deploy
|
||||
./install.sh
|
||||
"@
|
||||
|
||||
$installScript | ssh $sshOptions ${Username}@${ServerIP} "bash -s" 2>$null
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "WARNING: Installation may have errors. Check logs on server." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Configure Nginx and SSL
|
||||
Write-Host "[5/6] Configuring Nginx and SSL..." -ForegroundColor Yellow
|
||||
$nginxScript = @"
|
||||
cd $RemotePath/deploy
|
||||
sed -i "s/твой_домен.com/$Domain/g" nginx-setup.sh
|
||||
./nginx-setup.sh
|
||||
sed -i "s/твой_домен.com/$Domain/g" ssl-setup.sh
|
||||
sed -i "s/твой_email@example.com/admin@$Domain/g" ssl-setup.sh
|
||||
./ssl-setup.sh
|
||||
"@
|
||||
|
||||
$nginxScript | ssh $sshOptions ${Username}@${ServerIP} "bash -s" 2>$null
|
||||
|
||||
# Cleanup
|
||||
Write-Host "[6/6] Cleaning up..." -ForegroundColor Yellow
|
||||
if (Test-Path $TempDir) { Remove-Item $TempDir -Recurse -Force }
|
||||
if (Test-Path $ArchivePath) { Remove-Item $ArchivePath -Force }
|
||||
Set-Location $LocalProjectPath
|
||||
|
||||
# Final message
|
||||
Write-Host ""
|
||||
Write-Host "=== Deployment Complete! ===" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "Website: https://$Domain" -ForegroundColor Cyan
|
||||
Write-Host "Project: $RemotePath" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:" -ForegroundColor Yellow
|
||||
Write-Host "1. Configure .env file:" -ForegroundColor White
|
||||
Write-Host " ssh ${Username}@${ServerIP}" -ForegroundColor Gray
|
||||
Write-Host " nano $RemotePath/.env" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host "2. Fill in API keys (BOT_TOKEN, PIAPI_API_KEY, etc.)" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host "3. Restart service:" -ForegroundColor White
|
||||
Write-Host " systemctl restart pressa" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host "Useful commands:" -ForegroundColor Yellow
|
||||
Write-Host " ssh ${Username}@${ServerIP}" -ForegroundColor Gray
|
||||
Write-Host " sudo systemctl status pressa" -ForegroundColor Gray
|
||||
Write-Host " sudo journalctl -u pressa -f" -ForegroundColor Gray
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env pwsh
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Деплой ИИ Пресс-секретарь на сервер с Windows-компьютера
|
||||
.DESCRIPTION
|
||||
1. Архивирует проект
|
||||
2. Загружает на сервер через SCP
|
||||
3. Подключается по SSH и запускает установку
|
||||
4. Настраивает Nginx + SSL
|
||||
.NOTES
|
||||
Запуск: .\deploy.ps1
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ServerIP = "31.28.9.200",
|
||||
[string]$Username = "root",
|
||||
[string]$Password = "123edcxzaqws",
|
||||
[string]$Domain = "press.my-dpr.ru",
|
||||
[string]$RemotePath = "/opt/pressa",
|
||||
[string]$LocalProjectPath = "C:\python\pressa"
|
||||
)
|
||||
|
||||
# === ЦВЕТА ===
|
||||
$Red = "`e[31m"
|
||||
$Green = "`e[32m"
|
||||
$Yellow = "`e[33m"
|
||||
$Blue = "`e[34m"
|
||||
$NC = "`e[0m"
|
||||
|
||||
function Write-Status($msg) { Write-Host "${Green}[✓]${NC} $msg" }
|
||||
function Write-Info($msg) { Write-Host "${Blue}[i]${NC} $msg" }
|
||||
function Write-Warning($msg) { Write-Host "${Yellow}[!]${NC} $msg" }
|
||||
function Write-Error($msg) { Write-Host "${Red}[✗]${NC} $msg" }
|
||||
|
||||
# === ПРОВЕРКА SSH/SCP ===
|
||||
Write-Info "Проверка наличия SSH и SCP..."
|
||||
|
||||
$ssh = Get-Command ssh -ErrorAction SilentlyContinue
|
||||
$scp = Get-Command scp -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $ssh -or -not $scp) {
|
||||
Write-Error "SSH или SCP не найдены!"
|
||||
Write-Info "Установи OpenSSH Client:"
|
||||
Write-Info " Параметры → Приложения → Дополнительные компоненты → OpenSSH Client"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Status "SSH и SCP найдены"
|
||||
|
||||
# === СОЗДАНИЕ АРХИВА ===
|
||||
Write-Info "Создание архива проекта..."
|
||||
|
||||
$TempDir = "$env:TEMP\pressa-deploy"
|
||||
$ArchiveName = "pressa-deploy.tar.gz"
|
||||
$ArchivePath = "$env:TEMP\$ArchiveName"
|
||||
|
||||
# Удаляем старый архив
|
||||
if (Test-Path $ArchivePath) {
|
||||
Remove-Item $ArchivePath -Force
|
||||
}
|
||||
|
||||
# Создаём временную копию без лишнего
|
||||
if (Test-Path $TempDir) {
|
||||
Remove-Item $TempDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $TempDir | Out-Null
|
||||
|
||||
# Копируем нужные файлы
|
||||
$ItemsToCopy = @(
|
||||
"cabinet_service",
|
||||
"ai_core",
|
||||
"config.py",
|
||||
"requirements.txt",
|
||||
"deploy"
|
||||
)
|
||||
|
||||
foreach ($item in $ItemsToCopy) {
|
||||
$src = Join-Path $LocalProjectPath $item
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Path $src -Destination $TempDir -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Обновляем домен в скриптах
|
||||
$nginxScript = Join-Path $TempDir "deploy\nginx-setup.sh"
|
||||
if (Test-Path $nginxScript) {
|
||||
$content = Get-Content $nginxScript -Raw
|
||||
$content = $content.Replace('твой_домен.com', $Domain)
|
||||
Set-Content -Path $nginxScript -Value $content -NoNewline
|
||||
}
|
||||
|
||||
$sslScript = Join-Path $TempDir "deploy\ssl-setup.sh"
|
||||
if (Test-Path $sslScript) {
|
||||
$content = Get-Content $sslScript -Raw
|
||||
$content = $content.Replace('твой_домен.com', $Domain)
|
||||
$content = $content.Replace('твой_email@example.com', "admin@$Domain")
|
||||
Set-Content -Path $sslScript -Value $content -NoNewline
|
||||
}
|
||||
|
||||
# Создаём архив
|
||||
Write-Info "Архивирование..."
|
||||
Set-Location $env:TEMP
|
||||
# Используем tar если есть, иначе Compress-Archive
|
||||
$tar = Get-Command tar -ErrorAction SilentlyContinue
|
||||
if ($tar) {
|
||||
tar -czf $ArchiveName -C $TempDir .
|
||||
} else {
|
||||
Compress-Archive -Path "$TempDir\*" -DestinationPath "$env:TEMP\pressa-deploy.zip" -Force
|
||||
$ArchiveName = "pressa-deploy.zip"
|
||||
$ArchivePath = "$env:TEMP\$ArchiveName"
|
||||
}
|
||||
|
||||
Write-Status "Архив создан: $ArchivePath ($((Get-Item $ArchivePath).Length / 1MB | Format-Number -Format '0.0') MB)"
|
||||
|
||||
# === ЗАГРУЗКА НА СЕРВЕР ===
|
||||
Write-Info "Загрузка на сервер $ServerIP..."
|
||||
|
||||
# Создаём директорию на сервере
|
||||
$sshCmd = "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${Username}@${ServerIP}"
|
||||
$createDir = "mkdir -p $RemotePath && rm -rf $RemotePath/*"
|
||||
|
||||
Write-Info "Создание директории на сервере..."
|
||||
$createDir | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${Username}@${ServerIP} "bash -s"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Не удалось подключиться по SSH!"
|
||||
Write-Info "Проверь:"
|
||||
Write-Info " 1. IP-адрес сервера"
|
||||
Write-Info " 2. Логин и пароль"
|
||||
Write-Info " 3. Доступность порта 22"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Status "Подключение установлено"
|
||||
|
||||
# Загружаем архив
|
||||
Write-Info "Загрузка архива..."
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $ArchivePath "${Username}@${ServerIP}:${RemotePath}/"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Ошибка загрузки файлов!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Status "Файлы загружены"
|
||||
|
||||
# === РАСПАКОВКА И УСТАНОВКА ===
|
||||
Write-Info "Распаковка и установка на сервере..."
|
||||
|
||||
$remoteCommands = @"
|
||||
cd $RemotePath
|
||||
|
||||
# Распаковка
|
||||
if [ -f "$ArchiveName" ]; then
|
||||
tar -xzf "$ArchiveName"
|
||||
rm "$ArchiveName"
|
||||
elif [ -f "pressa-deploy.zip" ]; then
|
||||
apt-get install -y unzip
|
||||
unzip -q "pressa-deploy.zip"
|
||||
rm "pressa-deploy.zip"
|
||||
fi
|
||||
|
||||
# Перемещаем файлы из подпапки
|
||||
if [ -d "cabinet_service" ]; then
|
||||
# Уже в корне
|
||||
echo "Файлы на месте"
|
||||
else
|
||||
# Ищем папку с файлами
|
||||
for dir in */; do
|
||||
if [ -d "\$dir/cabinet_service" ]; then
|
||||
mv "\$dir"/* .
|
||||
rm -rf "\$dir"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Установка прав
|
||||
chmod +x deploy/*.sh
|
||||
|
||||
# Запуск установки
|
||||
echo "=== Запуск install.sh ==="
|
||||
cd deploy
|
||||
./install.sh
|
||||
|
||||
echo ""
|
||||
echo "=== Установка завершена ==="
|
||||
echo ""
|
||||
echo "Не забудь настроить .env файл:"
|
||||
echo " nano $RemotePath/.env"
|
||||
echo ""
|
||||
echo "Перезапуск сервиса:"
|
||||
echo " systemctl restart pressa"
|
||||
"@
|
||||
|
||||
Write-Info "Запуск установочных скриптов (это займет 5-10 минут)..."
|
||||
$remoteCommands | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${Username}@${ServerIP} "bash -s"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warning "Возможны ошибки при установке. Проверь логи на сервере."
|
||||
}
|
||||
|
||||
# === НАСТРОЙКА NGINX И SSL ===
|
||||
Write-Info "Настройка Nginx и SSL..."
|
||||
|
||||
$nginxCommands = @"
|
||||
cd $RemotePath/deploy
|
||||
|
||||
# Nginx
|
||||
sed -i "s/твой_домен.com/$Domain/g" nginx-setup.sh
|
||||
./nginx-setup.sh
|
||||
|
||||
# SSL
|
||||
sed -i "s/твой_домен.com/$Domain/g" ssl-setup.sh
|
||||
sed -i "s/твой_email@example.com/admin@$Domain/g" ssl-setup.sh
|
||||
./ssl-setup.sh
|
||||
|
||||
echo ""
|
||||
echo "=== Готово! ==="
|
||||
echo "Сайт доступен по адресу: https://$Domain"
|
||||
"@
|
||||
|
||||
$nginxCommands | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${Username}@${ServerIP} "bash -s"
|
||||
|
||||
# === ФИНАЛ ===
|
||||
Write-Status "Деплой завершён!"
|
||||
Write-Info ""
|
||||
Write-Info "🌐 Сайт: https://$Domain"
|
||||
Write-Info "📁 Проект: $RemotePath"
|
||||
Write-Info ""
|
||||
Write-Info "Полезные команды:"
|
||||
Write-Info " ssh ${Username}@${ServerIP}"
|
||||
Write-Info " sudo systemctl status pressa"
|
||||
Write-Info " sudo journalctl -u pressa -f"
|
||||
Write-Info ""
|
||||
Write-Warning "⚠️ Не забудь настроить .env файл на сервере!"
|
||||
Write-Info " ssh ${Username}@${ServerIP} 'nano $RemotePath/.env'"
|
||||
|
||||
# Очистка
|
||||
if (Test-Path $TempDir) {
|
||||
Remove-Item $TempDir -Recurse -Force
|
||||
}
|
||||
|
||||
Set-Location $LocalProjectPath
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
|
||||
# === Полная установка ИИ Пресс-секретарь ===
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Полная установка ИИ Пресс-секретарь ==="
|
||||
echo ""
|
||||
|
||||
# 1. Установка системных зависимостей
|
||||
echo "[1/7] Установка системных зависимостей..."
|
||||
sudo apt update
|
||||
sudo apt install -y python3.12 python3.12-venv python3-pip nginx certbot python3-certbot-nginx
|
||||
|
||||
# 2. Клонирование проекта
|
||||
echo "[2/7] Клонирование проекта..."
|
||||
sudo mkdir -p /opt/pressa
|
||||
git clone https://github.com/твой_репозиторий/pressa.git /opt/pressa
|
||||
cd /opt/pressa
|
||||
|
||||
# 3. Деплой
|
||||
echo "[3/7] Деплой приложения..."
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh
|
||||
|
||||
# 4. Nginx
|
||||
echo "[4/7] Настройка Nginx..."
|
||||
chmod +x nginx-setup.sh
|
||||
./nginx-setup.sh
|
||||
|
||||
# 5. SSL
|
||||
echo "[5/7] Настройка SSL..."
|
||||
read -p "Есть домен? (y/N): " HAS_DOMAIN
|
||||
if [ "$HAS_DOMAIN" == "y" ] || [ "$HAS_DOMAIN" == "Y" ]; then
|
||||
read -p "Введите домен (например, example.com): " DOMAIN
|
||||
chmod +x ssl-setup.sh
|
||||
./ssl-setup.sh
|
||||
fi
|
||||
|
||||
# 6. Бэкап
|
||||
echo "[6/7] Настройка бэкапов..."
|
||||
chmod +x backup.sh
|
||||
echo "0 3 * * * /opt/pressa/deploy/backup.sh" | crontab -
|
||||
|
||||
# 7. Проверка
|
||||
echo "[7/7] Проверка..."
|
||||
sudo systemctl status pressa --no-pager -l
|
||||
|
||||
echo ""
|
||||
echo "=== Установка завершена! ==="
|
||||
echo ""
|
||||
echo "Доступ:"
|
||||
echo " - Без Nginx: http://localhost:8001"
|
||||
echo " - С Nginx: http://$(hostname -I | awk '{print $1}'):80"
|
||||
echo ""
|
||||
echo "Следующие шаги:"
|
||||
echo " 1. Отредактируй .env файл: nano /opt/pressa/.env"
|
||||
echo " 2. Заполни переменные окружения"
|
||||
echo " 3. Перезапусти сервис: sudo systemctl restart pressa"
|
||||
echo ""
|
||||
echo "Управление:"
|
||||
echo " - Статус: sudo systemctl status pressa"
|
||||
echo " - Логи: sudo journalctl -u pressa -f"
|
||||
echo " - Бэкап: sudo /opt/pressa/deploy/backup.sh"
|
||||
echo " - Восстановление: sudo /opt/pressa/deploy/restore.sh"
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
# === Восстановление из бэкапа ===
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="/opt/backups"
|
||||
APP_DIR="/opt/pressa"
|
||||
|
||||
echo "=== Восстановление из бэкапа ==="
|
||||
echo ""
|
||||
echo "Доступные бэкапы:"
|
||||
ls -1 $BACKUP_DIR/pressa_*.tar.gz 2>/dev/null || echo "Бэкапы не найдены"
|
||||
|
||||
echo ""
|
||||
read -p "Введите имя файла бэкапа (например, pressa_20260827_143022.tar.gz): " BACKUP_FILE
|
||||
|
||||
if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||
echo "❌ Файл не найден: $BACKUP_DIR/$BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Восстановление: $BACKUP_DIR/$BACKUP_FILE"
|
||||
read -p "Продолжить? (y/N): " CONFIRM
|
||||
|
||||
if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then
|
||||
echo "Отмена"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Остановка сервиса
|
||||
echo "Остановка сервиса..."
|
||||
sudo systemctl stop pressa
|
||||
|
||||
# Создание папки
|
||||
mkdir -p $APP_DIR/cabinet_service
|
||||
|
||||
# Восстановление базы данных
|
||||
echo "Восстановление базы данных..."
|
||||
tar -xzf $BACKUP_DIR/$BACKUP_FILE -C $APP_DIR cabinet_service/users.db
|
||||
|
||||
# Восстановление медиа
|
||||
echo "Восстановление медиа..."
|
||||
tar -xzf $BACKUP_DIR/$BACKUP_FILE -C $APP_DIR cabinet_service/media
|
||||
|
||||
# Установка прав
|
||||
echo "Установка прав..."
|
||||
sudo chown -R www-data:www-data $APP_DIR/cabinet_service
|
||||
|
||||
# Перезапуск
|
||||
echo "Перезапуск сервиса..."
|
||||
sudo systemctl start pressa
|
||||
|
||||
echo ""
|
||||
echo "✓ Восстановление завершено!"
|
||||
echo "Проверь статус: sudo systemctl status pressa"
|
||||
@@ -8,3 +8,4 @@ bcrypt>=4.1.0
|
||||
itsdangerous>=2.1.0
|
||||
jinja2>=3.1.0
|
||||
python-multipart>=0.0.9
|
||||
python-docx>=1.1.0
|
||||
Reference in new issue
Block a user