Kgshop commited on
Commit
79d2248
·
verified ·
1 Parent(s): d74e3c3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +329 -192
app.py CHANGED
@@ -1,5 +1,3 @@
1
-
2
-
3
  import os
4
  import uuid
5
  import json
@@ -14,61 +12,59 @@ from huggingface_hub import HfApi, hf_hub_download
14
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
15
  from dotenv import load_dotenv
16
 
17
- # Загружаем переменные окружения из файла .env
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  load_dotenv()
19
 
20
  app = Flask(__name__)
21
- # Устанавливаем секретный ключ для работы flash-сообщений
22
- app.secret_key = os.getenv("FLASK_SECRET_KEY", "your_unique_secret_key_for_eva_app")
23
 
24
- # --- Конфигурация Hugging Face ---
25
- # ID репозитория на Hugging Face для хранения метаданных сайтов
26
- REPO_ID = "Kgshop/testsynk"
27
- # Токены для доступа к Hugging Face (для записи и чтения)
28
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
29
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
30
 
31
- # Файл, который будет синхронизироваться с Hugging Face. В нем хранятся метаданные всех сгенерированных сайтов.
32
  DATA_FILE = 'generated_sites_metadata.json'
33
  SYNC_FILES = [DATA_FILE]
34
 
35
- # Каталог для потенциальных временных файлов или статических ассетов (в данной реализации используется минимально)
36
- GENERATED_SITES_DIR = 'generated_sites'
37
-
38
- # Настройки для скачивания/загрузки
39
  DOWNLOAD_RETRIES = 3
40
  DOWNLOAD_DELAY = 5
41
- BACKUP_INTERVAL_SECONDS = 1800 # 30 минут для периодического бэкапа
42
 
43
- # Настройка логирования
44
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
45
 
46
- # Убедимся, что каталог для сгенерированных сайтов существует (хотя сами сайты теперь не сохраняются как отдельные файлы)
47
- if not os.path.exists(GENERATED_SITES_DIR):
48
- os.makedirs(GENERATED_SITES_DIR)
49
-
50
- # --- Утилитарные функции для работы с Hugging Face (адаптировано из Кода 2) ---
51
 
52
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
53
- """
54
- Скачивает файлы базы данных с Hugging Face Hub.
55
- Если файл не найден на HF и локально отсутствует, создает пустой.
56
- """
57
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
58
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE не установлены. Скачивание может завершиться неудачей для приватных репозиториев.")
59
-
60
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
61
-
62
  files_to_download = [specific_file] if specific_file else SYNC_FILES
63
  logging.info(f"Попытка скачивания файлов {files_to_download} из репозитория {REPO_ID}...")
64
  all_successful = True
65
-
66
  for file_name in files_to_download:
67
  success = False
68
  for attempt in range(retries + 1):
69
  try:
70
  logging.info(f"Скачивание {file_name} (Попытка {attempt + 1}/{retries + 1})...")
71
- local_path = hf_hub_download(
72
  repo_id=REPO_ID,
73
  filename=file_name,
74
  repo_type="dataset",
@@ -78,7 +74,7 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
78
  force_download=True,
79
  resume_download=False
80
  )
81
- logging.info(f"Файл {file_name} успешно скачан в {local_path}.")
82
  success = True
83
  break
84
  except RepositoryNotFoundError:
@@ -91,40 +87,32 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
91
  try:
92
  if file_name == DATA_FILE:
93
  with open(file_name, 'w', encoding='utf-8') as f:
94
- json.dump({}, f) # Создаем пустой JSON-объект для метаданных
95
  logging.info(f"Создан пустой локальный файл {file_name}, так как он не был найден на HF.")
96
  except Exception as create_e:
97
  logging.error(f"Не удалось создать пустой локальный файл {file_name}: {create_e}")
98
- success = False # Все равно считаем, что для этого файла не удалось
99
  break
100
  else:
101
  logging.error(f"HTTP-ошибка при скачивании {file_name} (Попытка {attempt + 1}): {e}. Повторная попытка через {delay}с...")
102
  except Exception as e:
103
  logging.error(f"Неожиданная ошибка при скачивании {file_name} (Попытка {attempt + 1}): {e}. Повторная попытка через {delay}с...", exc_info=True)
104
-
105
  if attempt < retries:
106
  time.sleep(delay)
107
-
108
  if not success:
109
  logging.error(f"Не удалось скачать {file_name} после {retries + 1} попыток.")
110
  all_successful = False
111
-
112
  logging.info(f"Процесс скачивания завершен. Общий успех: {all_successful}")
113
  return all_successful
114
 
115
  def upload_db_to_hf(specific_file=None):
116
- """
117
- Загружает файлы базы данных на Hugging Face Hub.
118
- """
119
  if not HF_TOKEN_WRITE:
120
  logging.warning("HF_TOKEN (для записи) не установлен. Пропускаем загрузку на Hugging Face.")
121
  return
122
-
123
  try:
124
  api = HfApi()
125
  files_to_upload = [specific_file] if specific_file else SYNC_FILES
126
  logging.info(f"Начало загрузки файлов {files_to_upload} в репозиторий HF {REPO_ID}...")
127
-
128
  for file_name in files_to_upload:
129
  if os.path.exists(file_name):
130
  try:
@@ -146,9 +134,6 @@ def upload_db_to_hf(specific_file=None):
146
  logging.error(f"Общая ошибка при инициализации или процессе загрузки Hugging Face: {e}", exc_info=True)
147
 
148
  def periodic_backup():
149
- """
150
- Запускает периодическое резервное копирование данных на Hugging Face.
151
- """
152
  logging.info(f"Настройка периодического резервного копирования каждые {BACKUP_INTERVAL_SECONDS} секунд.")
153
  while True:
154
  time.sleep(BACKUP_INTERVAL_SECONDS)
@@ -156,13 +141,7 @@ def periodic_backup():
156
  upload_db_to_hf()
157
  logging.info("Периодическое резервное копирование завершено.")
158
 
159
- # --- Функции для сохранения/загрузки метаданных сгенерированных сайтов (адаптировано из Кода 2) ---
160
-
161
  def load_site_metadata():
162
- """
163
- Загружает метаданные сгенерированных сайтов из DATA_FILE.
164
- Если файл не найден или поврежден, пытается скачать с Hugging Face.
165
- """
166
  default_data = {}
167
  try:
168
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
@@ -170,13 +149,12 @@ def load_site_metadata():
170
  logging.info(f"Локальные метаданные сайта успешно загружены из {DATA_FILE}")
171
  if not isinstance(data, dict):
172
  logging.warning(f"Локальный файл {DATA_FILE} не является словарем. Попытка скачивания.")
173
- raise FileNotFoundError # Считаем поврежденным, пытаемся скачать
174
  return data
175
  except FileNotFoundError:
176
  logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачивания с HF.")
177
  except json.JSONDecodeError:
178
  logging.error(f"Ошибка декодирования JSON в локальном файле {DATA_FILE}. Файл может быть поврежден. Попытка скачивания.")
179
-
180
  if download_db_from_hf(specific_file=DATA_FILE):
181
  try:
182
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
@@ -204,36 +182,25 @@ def load_site_metadata():
204
  return default_data
205
 
206
  def save_site_metadata(data):
207
- """
208
- Сохраняет метаданные сгенерированных сайтов в DATA_FILE и загружает на Hugging Face.
209
- """
210
  try:
211
  if not isinstance(data, dict):
212
  logging.error("Попытка сохранить недопустимую структуру данных (не словарь) для метаданных сайта. Отмена сохранения.")
213
  return
214
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
215
- json.dump(data, file, ensure_ascii=False, indent=2) # indent=2 для читаемости JSON
216
  logging.info(f"Метаданные сайта успешно сохранены в {DATA_FILE}")
217
  upload_db_to_hf(specific_file=DATA_FILE)
218
  except Exception as e:
219
  logging.error(f"Ошибка сохранения метаданных сайта в {DATA_FILE}: {e}", exc_info=True)
220
 
221
- # --- Конфигурация Google Generative AI и промпт ---
222
-
223
- # Google API ключ для генерации контента
224
  API_KEY_INTERNAL = os.getenv("GOOGLE_API_KEY")
225
 
226
  def generate_site_json_from_prompt(user_prompt):
227
- """
228
- Генерирует JSON-объект, описывающий структуру сайта, на основе запроса пользователя,
229
- используя Google Generative AI.
230
- """
231
  try:
232
  genai.configure(api_key=API_KEY_INTERNAL)
233
  except Exception as e:
234
  logging.error(f"Ошибка настройки GenAI: {e}")
235
  raise ValueError(f"Не удалось настроить Google AI. Проблема с конфигурацией. Ошибка: {e}")
236
-
237
  if not user_prompt or not user_prompt.strip():
238
  raise ValueError("Текстовый запрос (промпт) не может быть пустым.")
239
 
@@ -243,7 +210,7 @@ def generate_site_json_from_prompt(user_prompt):
243
  "Этот JSON-объект будет использоваться Flask-приложением для динамического рендеринга сайта. "
244
  "Не включай HTML, Markdown или пояснительный текст за пределами JSON. Выводи только JSON-строку. "
245
  "JSON должен быть валидным и напрямую парсируемым. "
246
- "Для URL-адресов изображений используй общие сервисы-заполнители, такие как `https://placehold.co/600x400?text=Image` или аналогичные, "
247
  "либо опиши их текстом, если заполнители не подходят. "
248
  "Убедись, что любой текстовый контент, содержащий символы новой строки, представлен с помощью `\\n` в JSON-строке."
249
  "\n\nJSON Schema:\n"
@@ -252,75 +219,114 @@ def generate_site_json_from_prompt(user_prompt):
252
  " \"site_title\": \"string\",\n"
253
  " \"main_heading\": \"string\",\n"
254
  " \"tagline\": \"string\",\n"
 
 
 
 
 
255
  " \"sections\": [\n"
256
  " {\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  " \"title\": \"string\",\n"
258
- " \"content\": \"string\",\n"
259
- " \"type\": \"text\" // or \"list\", \"contact\", \"image_gallery\"\n"
260
  " }\n"
261
  " ],\n"
262
- " \"data_items\": [ // Это действует как \"база данных\" для простых списков/продуктов/элементов портфолио\n"
263
  " {\n"
264
  " \"id\": \"unique_string_or_number\",\n"
265
- " \"name\": \"string\",\n"
266
- " \"description\": \"string\",\n"
267
- " \"price\": \"number (optional)\",\n"
268
- " \"image_url\": \"string (optional, используй заполнитель или общее изображение)\",\n"
269
- " \"category\": \"string (optional)\",\n"
270
- " \"fields\": { \"key\": \"value\" } // произвольные дополнительные поля\n"
 
 
 
271
  " }\n"
272
  " ],\n"
273
- " \"contact_info\": { // Дополнительная контактная информация\n"
274
- " \"email\": \"string (optional)\",\n"
275
- " \"phone\": \"string (optional)\",\n"
276
- " \"address\": \"string (optional)\"\n"
277
- " },\n"
278
  " \"footer_text\": \"string (optional)\"\n"
279
  "}\n"
280
  "```\n"
281
- "- `sections`: Массив блоков контента.\n"
282
- " - `type: \"text\"` для общего текстового контента.\n"
283
- " - `type: \"list\"`, если нужно отобразить элементы из `data_items`. Убедись, что `data_items` заполнен, если используется этот тип.\n"
284
- " - `type: \"contact\"`, если это раздел контактов, будет использоваться `contact_info`. В `content` может быть дополнительный текст.\n"
285
- " - `type: \"image_gallery\"`, если запрошена галерея. Для изображений используй URL-адреса заполнителей. Массив `sections[].images` может быть добавлен, если необходимо.\n"
286
- "- `data_items`: Массив для простых продуктов, услуг, элементов портфолио и т.д. Они будут отображаться в виде карточек, если присутствует раздел ��ипа 'list'.\n"
287
- "- Убедись, что все строки в JSON правильно экранированы, особенно символы новой строки (используй `\\n`).\n"
288
  "- Выводи ТОЛЬКО JSON-код. Не включай никакого пояснительного текста или форматирования Markdown (например, ```json) за пределами JSON."
289
  )
290
-
291
  full_prompt = f"{system_instruction}\n\nПользовательский запрос: \"{user_prompt}\""
292
-
293
  response = None
294
  try:
295
- # Используем 'gemini-1.5-flash-latest' для лучшего следования JSON-формату
296
  model = genai.GenerativeModel('gemini-1.5-flash-latest')
297
  response = model.generate_content(full_prompt)
298
-
299
  generated_text = ""
300
  if hasattr(response, 'text') and response.text:
301
  generated_text = response.text
302
  elif response.parts:
303
  generated_text = "".join(part.text for part in response.parts if hasattr(part, 'text'))
304
-
305
  if not generated_text.strip():
306
  if hasattr(response, 'prompt_feedback') and response.prompt_feedback.block_reason:
307
  reason = response.prompt_feedback.block_reason
308
  raise ValueError(f"Генерация контента заблокирована из-за политики безопасности (причина: {reason}). Попробуйте другой запрос.")
309
  else:
310
  raise ValueError("Модель вернула пустой результат. Попробуйте изменить запрос.")
311
-
312
- # Удаляем возможное форматирование Markdown (```json...```)
313
  clean_text = generated_text.strip()
314
  if clean_text.startswith("```json"):
315
  clean_text = clean_text[7:]
316
  if clean_text.endswith("```"):
317
  clean_text = clean_text[:-3]
318
  generated_text = clean_text.strip()
319
-
320
- # Пытаемся распарсить как JSON для валидации
321
  parsed_json = json.loads(generated_text)
322
  return parsed_json
323
-
324
  except json.JSONDecodeError as jde:
325
  logging.error(f"Вывод ИИ был невалидным JSON: {generated_text[:500]}... Ошибка: {jde}")
326
  raise ValueError("Модель сгенерировала невалидный JSON. Пожалуйста, попробуйте еще раз или измените запрос.")
@@ -346,9 +352,6 @@ def generate_site_json_from_prompt(user_prompt):
346
  else:
347
  raise ValueError(f"Ошибка при генерации данных сайта: {e}")
348
 
349
- # --- HTML-шаблоны для UI EVA и для сгенерированных сайтов ---
350
-
351
- # Основной HTML-шаблон для главной страницы EVA, включающий форму и список сгенерированных сайтов
352
  index_page_template = """
353
  <!DOCTYPE html>
354
  <html lang="ru">
@@ -425,7 +428,7 @@ index_page_template = """
425
  }
426
 
427
  body {
428
- font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
429
  margin: 0;
430
  padding: 20px env(safe-area-inset-top) 20px env(safe-area-inset-bottom);
431
  background-color: var(--bg-color);
@@ -445,7 +448,7 @@ index_page_template = """
445
  padding: 25px 30px 30px 30px;
446
  border-radius: 24px;
447
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0,0,0,0.05);
448
- max-width: 780px; /* Increased max-width */
449
  width: calc(100% - 40px);
450
  box-sizing: border-box;
451
  margin-top: 30px;
@@ -666,7 +669,6 @@ index_page_template = """
666
  }
667
  }
668
 
669
-
670
  .site-card h3 {
671
  font-size: 20px;
672
  font-weight: 600;
@@ -678,6 +680,10 @@ index_page_template = """
678
  font-size: 14px;
679
  color: var(--secondary-text-color);
680
  margin-bottom: 8px;
 
 
 
 
681
  }
682
 
683
  .site-card .actions {
@@ -696,6 +702,10 @@ index_page_template = """
696
  text-decoration: none;
697
  cursor: pointer;
698
  transition: background-color 0.2s ease, transform 0.1s ease;
 
 
 
 
699
  }
700
 
701
  .site-card .actions a {
@@ -739,6 +749,7 @@ index_page_template = """
739
  font-size: 15px;
740
  font-weight: 500;
741
  text-align: center;
 
742
  }
743
 
744
  .flash-messages.success {
@@ -759,7 +770,6 @@ index_page_template = """
759
  border: 1px solid #ffeeba;
760
  }
761
 
762
-
763
  @media (max-width: 768px) {
764
  body {
765
  padding: 15px env(safe-area-inset-top) 15px env(safe-area-inset-bottom);
@@ -806,7 +816,7 @@ index_page_template = """
806
  margin-bottom: 20px;
807
  }
808
  .site-cards-grid {
809
- grid-template-columns: 1fr; /* Stack columns on small screens */
810
  }
811
  .site-card {
812
  padding: 18px;
@@ -833,7 +843,7 @@ index_page_template = """
833
  <form id="generate-form">
834
  <div class="form-group">
835
  <label for="prompt-input" class="input-label">Опишите сайт, который вы хотите создать</label>
836
- <textarea id="prompt-input" name="prompt" rows="6" required placeholder="Например: создай одностраничный сайт-портфолио для веб-дизайнера по имени Алия, с секциями 'Обо мне', 'Мои работы' и 'Контакты'. Используй современный минималистичный дизайн и добавь несколько примеров работ с описанием и ценой."></textarea>
837
  </div>
838
 
839
  <button type="submit" id="generate-button">Создать сайт</button>
@@ -859,10 +869,10 @@ index_page_template = """
859
  <p><strong>Создан:</strong> {{ site_info.timestamp }}</p>
860
  <p><strong>Основной заголовок:</strong> {{ site_info.ai_generated_data.main_heading | default('N/A') }}</p>
861
  <div class="actions">
862
- <a href="{{ url_for('serve_generated_site', site_id=site_id) }}" target="_blank">Открыть</a>
863
  <form method="POST" action="{{ url_for('delete_site') }}" style="display:inline;">
864
  <input type="hidden" name="site_id" value="{{ site_id }}">
865
- <button type="submit" onclick="return confirm('Вы уверены, что хотите удалить этот сайт?');">Удалить</button>
866
  </form>
867
  </div>
868
  </div>
@@ -922,7 +932,6 @@ index_page_template = """
922
  outputContainer.appendChild(link);
923
  copyButton.style.display = 'block';
924
  copyButton.dataset.copyText = window.location.origin + result.site_url;
925
- // Перезагрузка страницы для отображения нового сайта в списке
926
  setTimeout(() => {
927
  window.location.reload();
928
  }, 1000);
@@ -972,13 +981,11 @@ index_page_template = """
972
  outputContainer.classList.remove('loading');
973
  copyButton.style.display = 'none';
974
  }
975
-
976
  </script>
977
  </body>
978
  </html>
979
  """
980
 
981
- # Шаблон для рендеринга динамически сгенерированных сайтов
982
  dynamic_site_template = """
983
  <!DOCTYPE html>
984
  <html lang="ru">
@@ -986,198 +993,328 @@ dynamic_site_template = """
986
  <meta charset="UTF-8">
987
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
988
  <title>{{ site_data.site_title | default('Сгенерированный Сайт') }}</title>
989
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
 
990
  <style>
991
- body { font-family: 'Inter', sans-serif; margin: 0; padding: 0; background-color: #f4f7f6; color: #333; line-height: 1.6; }
992
- .container { max-width: 900px; margin: 30px auto; background-color: #fff; border-radius: 10px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); overflow: hidden; }
993
- header { background-color: #007bff; color: white; padding: 40px 20px; text-align: center; }
994
- header h1 { margin: 0; font-size: 2.8em; font-weight: 700; }
995
- header p { font-size: 1.2em; opacity: 0.9; margin-top: 10px; }
996
- .section-content { padding: 30px; border-bottom: 1px solid #eee; }
 
 
 
 
 
 
 
 
 
 
 
 
997
  .section-content:last-of-type { border-bottom: none; }
998
- .section-content h2 { color: #007bff; font-size: 2em; margin-bottom: 20px; text-align: center; }
999
- .text-content p { margin-bottom: 15px; font-size: 1.1em; line-height: 1.8; }
1000
- .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-top: 20px; }
1001
- .list-item { background-color: #f9f9f9; border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
1002
- .list-item img { max-width: 100%; height: 180px; object-fit: contain; border-radius: 5px; margin-bottom: 15px; background-color: white; padding: 5px; border: 1px solid #f0f0f0;}
1003
- .list-item h3 { color: #333; margin-top: 0; font-size: 1.4em; margin-bottom: 10px; }
1004
- .list-item p { font-size: 0.9em; color: #666; margin-bottom: 10px; }
1005
- .list-item .price { font-size: 1.2em; font-weight: 600; color: #28a745; margin-top: 10px; }
1006
- .contact-section p { font-size: 1.1em; margin-bottom: 10px; }
1007
- .contact-section a { color: #007bff; text-decoration: none; }
1008
- .contact-section a:hover { text-decoration: underline; }
1009
- footer { background-color: #333; color: white; text-align: center; padding: 20px; font-size: 0.9em; margin-top: 20px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1010
  @media (max-width: 768px) {
1011
- header h1 { font-size: 2em; }
1012
- header p { font-size: 1em; }
1013
- .section-content { padding: 20px; }
1014
- .section-content h2 { font-size: 1.6em; }
 
 
 
 
 
 
 
 
 
1015
  }
1016
  </style>
1017
  </head>
1018
  <body>
1019
  <div class="container">
 
1020
  <header>
1021
  <h1>{{ site_data.main_heading | default('Добро пожаловать!') }}</h1>
1022
  <p>{{ site_data.tagline | default('Ваш сгенерированный сайт готов.') }}</p>
1023
  </header>
 
1024
 
1025
  {% for section in site_data.sections %}
1026
  <div class="section-content">
1027
  <h2>{{ section.title }}</h2>
1028
- {% if section.type == 'text' %}
 
 
 
 
 
 
 
 
 
 
 
1029
  <div class="text-content">
1030
  <p>{{ section.content | replace('\\n', '<br>') | safe }}</p>
1031
  </div>
1032
  {% elif section.type == 'list' and site_data.data_items %}
1033
- <div class="list-grid">
1034
  {% for item in site_data.data_items %}
1035
- <div class="list-item">
 
1036
  {% if item.image_url %}<img src="{{ item.image_url }}" alt="{{ item.name }}">{% endif %}
1037
  <h3>{{ item.name }}</h3>
1038
  <p>{{ item.description | default('') | replace('\\n', '<br>') | safe }}</p>
1039
  {% if item.price %}<div class="price">{{ "%.2f"|format(item.price) }}</div>{% endif %}
1040
- {% if item.category %}<p style="font-size: 0.8em; color: #999;">Категория: {{ item.category }}</p>{% endif %}
1041
  {% for key, value in item.fields.items() %}
1042
  <p style="font-size: 0.85em; color: #555;"><strong>{{ key|capitalize }}:</strong> {{ value }}</p>
1043
  {% endfor %}
1044
  </div>
 
1045
  {% endfor %}
1046
  </div>
1047
- {% elif section.type == 'contact' and site_data.contact_info %}
1048
- <div class="contact-section text-content">
1049
- {% if site_data.contact_info.email %}<p><strong>Email:</strong> <a href="mailto:{{ site_data.contact_info.email }}">{{ site_data.contact_info.email }}</a></p>{% endif %}
1050
- {% if site_data.contact_info.phone %}<p><strong>Телефон:</strong> <a href="tel:{{ site_data.contact_info.phone }}">{{ site_data.contact_info.phone }}</a></p>{% endif %}
1051
- {% if site_data.contact_info.address %}<p><strong>Адрес:</strong> {{ site_data.contact_info.address }}</p>{% endif %}
1052
- <p>{{ section.content | default('') | replace('\\n', '<br>') | safe }}</p>
 
 
 
 
 
1053
  </div>
1054
- {% elif section.type == 'image_gallery' %}
1055
- <div class="list-grid">
1056
- {% if section.images %} {# Assuming images can be passed directly in the section object for gallery #}
1057
- {% for image_url in section.images %}
1058
- <div class="list-item">
1059
- <img src="{{ image_url }}" alt="Галерея">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
  </div>
1061
- {% endfor %}
1062
- {% else %}
1063
- <p style="text-align: center; color: #999;">Галерея изображений пока пуста.</p>
1064
  {% endif %}
1065
  </div>
1066
- <p>{{ section.content | default('') | replace('\\n', '<br>') | safe }}</p>
 
 
 
 
 
 
 
1067
  {% endif %}
1068
  </div>
1069
  {% endfor %}
1070
 
 
1071
  <footer>
1072
  <p>{{ site_data.footer_text | default(site_data.site_title + ' © ' + now.year|string + '. Все права защищены.') }}</p>
1073
  </footer>
 
1074
  </div>
1075
  </body>
1076
  </html>
1077
  """
1078
 
1079
- # --- Flask Маршруты ---
1080
-
1081
  @app.route('/')
1082
  def index():
1083
- """
1084
- Главная страница EVA, отображает форму генерации и список сгенерированных сайтов.
1085
- """
1086
  generated_sites = load_site_metadata()
1087
- # Сортируем сайты по времени создания (новые сверху) для удобства отображения
1088
  sorted_sites = dict(sorted(generated_sites.items(), key=lambda item: item[1].get('timestamp', ''), reverse=True))
1089
  return render_template_string(index_page_template, generated_sites=sorted_sites)
1090
 
1091
  @app.route('/generate', methods=['POST'])
1092
  def handle_generate():
1093
- """
1094
- Обрабатывает запрос на генерацию нового сайта.
1095
- Вызывает AI для получения JSON-описания сайта, сохраняет его и возвращает ссылку.
1096
- """
1097
  if 'prompt' not in request.form:
 
1098
  return jsonify({"error": "Текстовый запрос (промпт) не найден."}), 400
1099
-
1100
  user_prompt = request.form['prompt']
1101
-
1102
  if not user_prompt or not user_prompt.strip():
 
1103
  return jsonify({"error": "Текстовый запрос не может быть пустым."}), 400
1104
-
1105
  try:
1106
- # Получаем JSON-структуру данных сайта от AI
1107
  site_data_json = generate_site_json_from_prompt(user_prompt)
1108
-
1109
  if not site_data_json:
 
1110
  return jsonify({"error": "Сгенерированные данные сайта пусты."}), 500
1111
-
1112
- site_id = str(uuid.uuid4()) # Генерируем уникальный ID для нового сайта
1113
  generated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
1114
-
1115
- # Загружаем текущие метаданные, добавляем новый сайт и сохраняем
1116
  site_metadata = load_site_metadata()
1117
  site_metadata[site_id] = {
1118
  "timestamp": generated_at,
1119
  "ai_generated_data": site_data_json
1120
  }
1121
  save_site_metadata(site_metadata)
1122
-
1123
- # Формируем URL для доступа к сгенерированному сайту
1124
  site_url = url_for('serve_generated_site', site_id=site_id)
 
1125
  return jsonify({"site_url": site_url})
1126
-
1127
  except ValueError as ve:
1128
  logging.error(f"Ошибка генерации (ValueError): {ve}")
 
1129
  return jsonify({"error": str(ve)}), 400
1130
  except Exception as e:
1131
  logging.error(f"Неожиданная ошибка во время генерации сайта: {e}", exc_info=True)
 
1132
  return jsonify({"error": f"Внутренняя ошибка сервера при генерации сайта: {e}"}), 500
1133
 
1134
  @app.route('/generated_site/<site_id>')
1135
  def serve_generated_site(site_id):
1136
- """
1137
- Отображает динамически сгенерированный сайт по его ID.
1138
- Загружает JSON-данные сайта и рендерит их с помощью предопределенного шаблона.
1139
- """
1140
  site_metadata = load_site_metadata()
1141
  site_info = site_metadata.get(site_id)
1142
-
1143
  if not site_info:
1144
  flash(f"Сайт с ID '{site_id}' не найден.", 'error')
1145
  return redirect(url_for('index'))
1146
-
1147
  site_data = site_info.get('ai_generated_data')
1148
  if not site_data:
1149
  flash(f"Данные для сайта с ID '{site_id}' повреждены.", 'error')
1150
  return redirect(url_for('index'))
1151
-
1152
- # Передаем объект datetime для использования года в футере
1153
  return render_template_string(dynamic_site_template, site_data=site_data, now=datetime.now())
1154
 
1155
  @app.route('/delete_site', methods=['POST'])
1156
  def delete_site():
1157
- """
1158
- Удаляет сгенерированный сайт по его ID.
1159
- """
1160
  site_id_to_delete = request.form.get('site_id')
1161
  if not site_id_to_delete:
1162
  flash("ID сайта для удаления не предоставлен.", 'error')
1163
  return redirect(url_for('index'))
1164
-
1165
  site_metadata = load_site_metadata()
1166
  if site_id_to_delete in site_metadata:
1167
  del site_metadata[site_id_to_delete]
1168
- save_site_metadata(site_metadata) # Сохраняем изменения и синхронизируем с HF
1169
  flash(f"Сайт с ID '{site_id_to_delete}' успешно удален.", 'success')
1170
  else:
1171
  flash(f"Сайт с ID '{site_id_to_delete}' не найден.", 'warning')
1172
-
1173
  return redirect(url_for('index'))
1174
 
1175
- # --- Инициализация и запуск приложения ---
1176
-
1177
  if __name__ == '__main__':
1178
  logging.info("Приложение запускается. Выполняется первоначальная загрузка/скачивание данных...")
1179
- download_db_from_hf() # Попытка первоначального скачивания файла метаданных
1180
- load_site_metadata() # Загружаем его (или создаем по умолчанию, если не найден/скачан)
1181
  logging.info("Первоначальная загрузка данных завершена.")
1182
 
1183
  if API_KEY_INTERNAL is None:
 
 
 
1
  import os
2
  import uuid
3
  import json
 
12
  from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
13
  from dotenv import load_dotenv
14
 
15
+ # --- REQUIREMENTS.TXT ---
16
+ # flask
17
+ # google-generativeai
18
+ # huggingface-hub
19
+ # python-dotenv
20
+ #
21
+ # Установите зависимости, выполнив:
22
+ # pip install -r requirements.txt
23
+ # Или вручную:
24
+ # pip install Flask google-generativeai huggingface-hub python-dotenv
25
+ #
26
+ # --- .ENV FILE CONFIGURATION ---
27
+ # Создайте файл .env в той же директории, что и app.py, со следующим содержимым:
28
+ # GOOGLE_API_KEY="ВАШ_GOOGLE_GEMINI_API_КЛЮЧ"
29
+ # HF_TOKEN="ВАШ_HUGGING_FACE_WRITE_TOKEN"
30
+ # HF_TOKEN_READ="ВАШ_HUGGING_FACE_READ_TOKEN" # Может быть тем же, что и HF_TOKEN, если у вас есть права на запись
31
+ # FLASK_SECRET_KEY="СЛУЧАЙНАЯ_СЕКРЕТНАЯ_СТРОКА_ДЛЯ_FLASK"
32
+ # -----------------------------
33
+
34
  load_dotenv()
35
 
36
  app = Flask(__name__)
37
+ app.secret_key = os.getenv("FLASK_SECRET_KEY", "fallback_secret_key_if_not_set")
 
38
 
39
+ REPO_ID = "Kgshop/testsynk"
 
 
 
40
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
41
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
42
 
 
43
  DATA_FILE = 'generated_sites_metadata.json'
44
  SYNC_FILES = [DATA_FILE]
45
 
 
 
 
 
46
  DOWNLOAD_RETRIES = 3
47
  DOWNLOAD_DELAY = 5
48
+ BACKUP_INTERVAL_SECONDS = 1800 # 30 минут
49
 
 
50
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
51
 
52
+ if not os.path.exists('generated_sites'):
53
+ os.makedirs('generated_sites')
 
 
 
54
 
55
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
 
 
 
 
56
  if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
57
  logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE не установлены. Скачивание может завершиться неудачей для приватных репозиториев.")
 
58
  token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
 
59
  files_to_download = [specific_file] if specific_file else SYNC_FILES
60
  logging.info(f"Попытка скачивания файлов {files_to_download} из репозитория {REPO_ID}...")
61
  all_successful = True
 
62
  for file_name in files_to_download:
63
  success = False
64
  for attempt in range(retries + 1):
65
  try:
66
  logging.info(f"Скачивание {file_name} (Попытка {attempt + 1}/{retries + 1})...")
67
+ hf_hub_download(
68
  repo_id=REPO_ID,
69
  filename=file_name,
70
  repo_type="dataset",
 
74
  force_download=True,
75
  resume_download=False
76
  )
77
+ logging.info(f"Файл {file_name} успешно скачан.")
78
  success = True
79
  break
80
  except RepositoryNotFoundError:
 
87
  try:
88
  if file_name == DATA_FILE:
89
  with open(file_name, 'w', encoding='utf-8') as f:
90
+ json.dump({}, f)
91
  logging.info(f"Создан пустой локальный файл {file_name}, так как он не был найден на HF.")
92
  except Exception as create_e:
93
  logging.error(f"Не удалось создать пустой локальный файл {file_name}: {create_e}")
94
+ success = False
95
  break
96
  else:
97
  logging.error(f"HTTP-ошибка при скачивании {file_name} (Попытка {attempt + 1}): {e}. Повторная попытка через {delay}с...")
98
  except Exception as e:
99
  logging.error(f"Неожиданная ошибка при скачивании {file_name} (Попытка {attempt + 1}): {e}. Повторная попытка через {delay}с...", exc_info=True)
 
100
  if attempt < retries:
101
  time.sleep(delay)
 
102
  if not success:
103
  logging.error(f"Не удалось скачать {file_name} после {retries + 1} попыток.")
104
  all_successful = False
 
105
  logging.info(f"Процесс скачивания завершен. Общий успех: {all_successful}")
106
  return all_successful
107
 
108
  def upload_db_to_hf(specific_file=None):
 
 
 
109
  if not HF_TOKEN_WRITE:
110
  logging.warning("HF_TOKEN (для записи) не установлен. Пропускаем загрузку на Hugging Face.")
111
  return
 
112
  try:
113
  api = HfApi()
114
  files_to_upload = [specific_file] if specific_file else SYNC_FILES
115
  logging.info(f"Начало загрузки файлов {files_to_upload} в репозиторий HF {REPO_ID}...")
 
116
  for file_name in files_to_upload:
117
  if os.path.exists(file_name):
118
  try:
 
134
  logging.error(f"Общая ошибка при инициализации или процессе загрузки Hugging Face: {e}", exc_info=True)
135
 
136
  def periodic_backup():
 
 
 
137
  logging.info(f"Настройка периодического резервного копирования каждые {BACKUP_INTERVAL_SECONDS} секунд.")
138
  while True:
139
  time.sleep(BACKUP_INTERVAL_SECONDS)
 
141
  upload_db_to_hf()
142
  logging.info("Периодическое резервное копирование завершено.")
143
 
 
 
144
  def load_site_metadata():
 
 
 
 
145
  default_data = {}
146
  try:
147
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
 
149
  logging.info(f"Локальные метаданные сайта успешно загружены из {DATA_FILE}")
150
  if not isinstance(data, dict):
151
  logging.warning(f"Локальный файл {DATA_FILE} не является словарем. Попытка скачивания.")
152
+ raise FileNotFoundError
153
  return data
154
  except FileNotFoundError:
155
  logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачивания с HF.")
156
  except json.JSONDecodeError:
157
  logging.error(f"Ошибка декодирования JSON в локальном файле {DATA_FILE}. Файл может быть поврежден. Попытка скачивания.")
 
158
  if download_db_from_hf(specific_file=DATA_FILE):
159
  try:
160
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
 
182
  return default_data
183
 
184
  def save_site_metadata(data):
 
 
 
185
  try:
186
  if not isinstance(data, dict):
187
  logging.error("Попытка сохранить недопустимую структуру данных (не словарь) для метаданных сайта. Отмена сохранения.")
188
  return
189
  with open(DATA_FILE, 'w', encoding='utf-8') as file:
190
+ json.dump(data, file, ensure_ascii=False, indent=2)
191
  logging.info(f"Метаданные сайта успешно сохранены в {DATA_FILE}")
192
  upload_db_to_hf(specific_file=DATA_FILE)
193
  except Exception as e:
194
  logging.error(f"Ошибка сохранения метаданных сайта в {DATA_FILE}: {e}", exc_info=True)
195
 
 
 
 
196
  API_KEY_INTERNAL = os.getenv("GOOGLE_API_KEY")
197
 
198
  def generate_site_json_from_prompt(user_prompt):
 
 
 
 
199
  try:
200
  genai.configure(api_key=API_KEY_INTERNAL)
201
  except Exception as e:
202
  logging.error(f"Ошибка настройки GenAI: {e}")
203
  raise ValueError(f"Не удалось настроить Google AI. Проблема с конфигурацией. Ошибка: {e}")
 
204
  if not user_prompt or not user_prompt.strip():
205
  raise ValueError("Текстовый запрос (промпт) не может быть пустым.")
206
 
 
210
  "Этот JSON-объект будет использоваться Flask-приложением для динамического рендеринга сайта. "
211
  "Не включай HTML, Markdown или пояснительный текст за пределами JSON. Выводи только JSON-строку. "
212
  "JSON должен быть валидным и напрямую парсируемым. "
213
+ "Для URL-адресов изображений используй общие сервисы-заполнители, такие как `https://placehold.co/600x400?text=Image` или `https://picsum.photos/600/400` или аналогичные, "
214
  "либо опиши их текстом, если заполнители не подходят. "
215
  "Убедись, что любой текстовый контент, содержащий символы новой строки, представлен с помощью `\\n` в JSON-строке."
216
  "\n\nJSON Schema:\n"
 
219
  " \"site_title\": \"string\",\n"
220
  " \"main_heading\": \"string\",\n"
221
  " \"tagline\": \"string\",\n"
222
+ " \"theme\": \"string\", // Suggested values: \"modern\", \"minimalist\", \"bold\", \"corporate\", \"creative\", \"playful\"\n"
223
+ " \"primary_color\": \"string\", // Hex color, e.g., \"#3498db\"\n"
224
+ " \"secondary_color\": \"string\", // Hex color, e.g., \"#2ecc71\"\n"
225
+ " \"font_family_heading\": \"string\", // CSS font-family, e.g., \"'Inter', sans-serif\"\n"
226
+ " \"font_family_body\": \"string\", // CSS font-family, e.g., \"'Open Sans', sans-serif\"\n"
227
  " \"sections\": [\n"
228
  " {\n"
229
+ " \"type\": \"hero\", // Large introductory section\n"
230
+ " \"title\": \"string\",\n"
231
+ " \"subtitle\": \"string\",\n"
232
+ " \"image_url\": \"string (optional, placeholder URL)\",\n"
233
+ " \"cta_text\": \"string (optional)\", // Call to action button text\n"
234
+ " \"cta_link\": \"string (optional)\" // Call to action button link\n"
235
+ " },\n"
236
+ " {\n"
237
+ " \"type\": \"text\", // General text content section\n"
238
+ " \"title\": \"string\",\n"
239
+ " \"content\": \"string\"\n"
240
+ " },\n"
241
+ " {\n"
242
+ " \"type\": \"list\", // Displays items from 'data_items'\n"
243
+ " \"title\": \"string\",\n"
244
+ " \"description\": \"string (optional)\",\n"
245
+ " \"filter_category\": \"string (optional)\" // If set, only show data_items with this category\n"
246
+ " },\n"
247
+ " {\n"
248
+ " \"type\": \"features\", // Grid of features, draws from data_items or custom section.items\n"
249
+ " \"title\": \"string\",\n"
250
+ " \"description\": \"string (optional)\",\n"
251
+ " \"items\": [ // Can specify items directly or draw from data_items\n"
252
+ " {\"name\": \"string\", \"description\": \"string\", \"icon\": \"string (e.g. 'fas fa-star')\"}\n"
253
+ " ]\n"
254
+ " },\n"
255
+ " {\n"
256
+ " \"type\": \"testimonials\", // Testimonials section, draws from data_items\n"
257
+ " \"title\": \"string\",\n"
258
+ " \"description\": \"string (optional)\"\n"
259
+ " },\n"
260
+ " {\n"
261
+ " \"type\": \"pricing\", // Pricing section, draws from data_items\n"
262
+ " \"title\": \"string\",\n"
263
+ " \"description\": \"string (optional)\"\n"
264
+ " },\n"
265
+ " {\n"
266
+ " \"type\": \"contact\", // Contact section with form structure\n"
267
+ " \"title\": \"string\",\n"
268
+ " \"content\": \"string (optional, for introductory text)\",\n"
269
+ " \"email\": \"string (optional)\",\n"
270
+ " \"phone\": \"string (optional)\",\n"
271
+ " \"address\": \"string (optional)\",\n"
272
+ " \"form_fields\": [ // Defines the form structure, no backend processing\n"
273
+ " {\"label\": \"string\", \"type\": \"text\"}, // or \"email\", \"textarea\"\n"
274
+ " {\"label\": \"Ваше сообщение\", \"type\": \"textarea\"}\n"
275
+ " ]\n"
276
+ " },\n"
277
+ " {\n"
278
+ " \"type\": \"image_gallery\", // Gallery of images\n"
279
  " \"title\": \"string\",\n"
280
+ " \"description\": \"string (optional)\",\n"
281
+ " \"images\": [\"url_string\", \"url_string\"] // Array of placeholder image URLs\n"
282
  " }\n"
283
  " ],\n"
284
+ " \"data_items\": [ // Acts as a 'database' for lists, features, testimonials, pricing plans\n"
285
  " {\n"
286
  " \"id\": \"unique_string_or_number\",\n"
287
+ " \"name\": \"string\", // For lists, features, pricing plan name, person name for testimonial\n"
288
+ " \"description\": \"string\", // For lists, features, testimonial text, pricing features\n"
289
+ " \"price\": \"number (optional)\", // For lists, pricing plans\n"
290
+ " \"image_url\": \"string (optional, placeholder URL)\", // For lists, image gallery, testimonial avatar\n"
291
+ " \"category\": \"string (optional)\", // For filtering lists\n"
292
+ " \"icon\": \"string (optional, e.g. 'fas fa-star')\", // For features\n"
293
+ " \"author\": \"string (optional)\", // For testimonials\n"
294
+ " \"role\": \"string (optional)\", // For testimonials\n"
295
+ " \"fields\": { \"key\": \"value\" } // Arbitrary additional fields\n"
296
  " }\n"
297
  " ],\n"
 
 
 
 
 
298
  " \"footer_text\": \"string (optional)\"\n"
299
  "}\n"
300
  "```\n"
301
+ "- `sections`: Массив блоков контента. AI должно варьировать типы секций в соответствии с запросом.\n"
302
+ "- Для `image_url` в `data_items` или `image_gallery` используй URL-адреса, например, `https://picsum.photos/400/300?random=1` или `https://placehold.co/400x300?text=Photo+Title`.\n"
303
+ "- Для `icon` в `features` используй классы Font Awesome 6 Free (например, `fas fa-star`, `fas fa-code`, `fas fa-check`).\n"
 
 
 
 
304
  "- Выводи ТОЛЬКО JSON-код. Не включай никакого пояснительного текста или форматирования Markdown (например, ```json) за пределами JSON."
305
  )
 
306
  full_prompt = f"{system_instruction}\n\nПользовательский запрос: \"{user_prompt}\""
 
307
  response = None
308
  try:
 
309
  model = genai.GenerativeModel('gemini-1.5-flash-latest')
310
  response = model.generate_content(full_prompt)
 
311
  generated_text = ""
312
  if hasattr(response, 'text') and response.text:
313
  generated_text = response.text
314
  elif response.parts:
315
  generated_text = "".join(part.text for part in response.parts if hasattr(part, 'text'))
 
316
  if not generated_text.strip():
317
  if hasattr(response, 'prompt_feedback') and response.prompt_feedback.block_reason:
318
  reason = response.prompt_feedback.block_reason
319
  raise ValueError(f"Генерация контента заблокирована из-за политики безопасности (причина: {reason}). Попробуйте другой запрос.")
320
  else:
321
  raise ValueError("Модель вернула пустой результат. Попробуйте изменить запрос.")
 
 
322
  clean_text = generated_text.strip()
323
  if clean_text.startswith("```json"):
324
  clean_text = clean_text[7:]
325
  if clean_text.endswith("```"):
326
  clean_text = clean_text[:-3]
327
  generated_text = clean_text.strip()
 
 
328
  parsed_json = json.loads(generated_text)
329
  return parsed_json
 
330
  except json.JSONDecodeError as jde:
331
  logging.error(f"Вывод ИИ был невалидным JSON: {generated_text[:500]}... Ошибка: {jde}")
332
  raise ValueError("Модель сгенерировала невалидный JSON. Пожалуйста, попробуйте еще раз или измените запрос.")
 
352
  else:
353
  raise ValueError(f"Ошибка при генерации данных сайта: {e}")
354
 
 
 
 
355
  index_page_template = """
356
  <!DOCTYPE html>
357
  <html lang="ru">
 
428
  }
429
 
430
  body {
431
+ font-family: -apple-system, BlinkMacMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
432
  margin: 0;
433
  padding: 20px env(safe-area-inset-top) 20px env(safe-area-inset-bottom);
434
  background-color: var(--bg-color);
 
448
  padding: 25px 30px 30px 30px;
449
  border-radius: 24px;
450
  box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0,0,0,0.05);
451
+ max-width: 780px;
452
  width: calc(100% - 40px);
453
  box-sizing: border-box;
454
  margin-top: 30px;
 
669
  }
670
  }
671
 
 
672
  .site-card h3 {
673
  font-size: 20px;
674
  font-weight: 600;
 
680
  font-size: 14px;
681
  color: var(--secondary-text-color);
682
  margin-bottom: 8px;
683
+ word-wrap: break-word;
684
+ }
685
+ .site-card p strong {
686
+ color: var(--text-color);
687
  }
688
 
689
  .site-card .actions {
 
702
  text-decoration: none;
703
  cursor: pointer;
704
  transition: background-color 0.2s ease, transform 0.1s ease;
705
+ display: inline-flex;
706
+ align-items: center;
707
+ justify-content: center;
708
+ gap: 5px;
709
  }
710
 
711
  .site-card .actions a {
 
749
  font-size: 15px;
750
  font-weight: 500;
751
  text-align: center;
752
+ margin-bottom: 25px;
753
  }
754
 
755
  .flash-messages.success {
 
770
  border: 1px solid #ffeeba;
771
  }
772
 
 
773
  @media (max-width: 768px) {
774
  body {
775
  padding: 15px env(safe-area-inset-top) 15px env(safe-area-inset-bottom);
 
816
  margin-bottom: 20px;
817
  }
818
  .site-cards-grid {
819
+ grid-template-columns: 1fr;
820
  }
821
  .site-card {
822
  padding: 18px;
 
843
  <form id="generate-form">
844
  <div class="form-group">
845
  <label for="prompt-input" class="input-label">Опишите сайт, который вы хотите создать</label>
846
+ <textarea id="prompt-input" name="prompt" rows="6" required placeholder="Например: создай сайт-портфолио для фотографа в минималистичном стиле, с разделами 'Обо мне', 'Портфолио' галереей), 'Услуги' ценами) и 'Контакты' с формой обратной связи. Используй синие и серые тона."></textarea>
847
  </div>
848
 
849
  <button type="submit" id="generate-button">Создать сайт</button>
 
869
  <p><strong>Создан:</strong> {{ site_info.timestamp }}</p>
870
  <p><strong>Основной заголовок:</strong> {{ site_info.ai_generated_data.main_heading | default('N/A') }}</p>
871
  <div class="actions">
872
+ <a href="{{ url_for('serve_generated_site', site_id=site_id) }}" target="_blank"><i class="fas fa-external-link-alt"></i> Открыть</a>
873
  <form method="POST" action="{{ url_for('delete_site') }}" style="display:inline;">
874
  <input type="hidden" name="site_id" value="{{ site_id }}">
875
+ <button type="submit" onclick="return confirm('Вы уверены, что хотите удалить этот сайт?');"><i class="fas fa-trash-alt"></i> Удалить</button>
876
  </form>
877
  </div>
878
  </div>
 
932
  outputContainer.appendChild(link);
933
  copyButton.style.display = 'block';
934
  copyButton.dataset.copyText = window.location.origin + result.site_url;
 
935
  setTimeout(() => {
936
  window.location.reload();
937
  }, 1000);
 
981
  outputContainer.classList.remove('loading');
982
  copyButton.style.display = 'none';
983
  }
 
984
  </script>
985
  </body>
986
  </html>
987
  """
988
 
 
989
  dynamic_site_template = """
990
  <!DOCTYPE html>
991
  <html lang="ru">
 
993
  <meta charset="UTF-8">
994
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
995
  <title>{{ site_data.site_title | default('Сгенерированный Сайт') }}</title>
996
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Open+Sans:wght@400;600&display=swap" rel="stylesheet">
997
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
998
  <style>
999
+ :root {
1000
+ --primary-color: {{ site_data.primary_color | default('#007bff') }};
1001
+ --secondary-color: {{ site_data.secondary_color | default('#2ecc71') }};
1002
+ --heading-font: {{ site_data.font_family_heading | default("'Inter', sans-serif") }};
1003
+ --body-font: {{ site_data.font_family_body | default("'Open Sans', sans-serif") }};
1004
+ --text-color: #333;
1005
+ --bg-color: #f4f7f6;
1006
+ --card-bg: #ffffff;
1007
+ --border-color: #e0e0e0;
1008
+ --light-bg: #f9f9f9;
1009
+ }
1010
+
1011
+ body { font-family: var(--body-font); margin: 0; padding: 0; background-color: var(--bg-color); color: var(--text-color); line-height: 1.6; }
1012
+ .container { max-width: 1000px; margin: 30px auto; background-color: var(--card-bg); border-radius: 10px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); overflow: hidden; }
1013
+ header { background-color: var(--primary-color); color: white; padding: 60px 20px; text-align: center; }
1014
+ header h1 { margin: 0; font-size: 3.2em; font-weight: 700; font-family: var(--heading-font); }
1015
+ header p { font-size: 1.4em; opacity: 0.9; margin-top: 10px; font-family: var(--heading-font); }
1016
+ .section-content { padding: 40px 30px; border-bottom: 1px solid var(--border-color); }
1017
  .section-content:last-of-type { border-bottom: none; }
1018
+ .section-content h2 { color: var(--primary-color); font-size: 2.5em; margin-bottom: 25px; text-align: center; font-family: var(--heading-font); }
1019
+ .section-content p { font-size: 1.1em; line-height: 1.8; text-align: center; max-width: 700px; margin: 0 auto 30px;}
1020
+
1021
+ /* Hero Section */
1022
+ .hero-section { background-color: var(--primary-color); color: white; padding: 80px 20px; text-align: center; }
1023
+ .hero-section h1 { font-size: 3.5em; margin-bottom: 15px; font-family: var(--heading-font); }
1024
+ .hero-section p { font-size: 1.5em; margin-bottom: 30px; opacity: 0.9; font-family: var(--heading-font); }
1025
+ .hero-section img { max-width: 100%; height: auto; border-radius: 8px; margin-top: 30px; box-shadow: 0 8px 20px rgba(0,0,0,0.2); }
1026
+ .hero-section .cta-button {
1027
+ display: inline-block; padding: 15px 30px; background-color: var(--secondary-color); color: white;
1028
+ text-decoration: none; border-radius: 50px; font-size: 1.2em; font-weight: 600; transition: background-color 0.3s ease, transform 0.2s ease;
1029
+ }
1030
+ .hero-section .cta-button:hover { background-color: darken(var(--secondary-color), 10%); transform: translateY(-2px); }
1031
+
1032
+ /* General Text Section */
1033
+ .text-content { text-align: justify; }
1034
+ .text-content p { text-align: left; }
1035
+
1036
+ /* List/Gallery Grid */
1037
+ .grid-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 30px; margin-top: 20px; }
1038
+ .grid-item { background-color: var(--light-bg); border: 1px solid var(--border-color); border-radius: 12px; padding: 25px; text-align: center; box-shadow: 0 2px 10px rgba(0,0,0,0.05); transition: transform 0.3s ease, box-shadow 0.3s ease; }
1039
+ .grid-item:hover { transform: translateY(-5px); box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
1040
+ .grid-item img { max-width: 100%; height: 200px; object-fit: cover; border-radius: 8px; margin-bottom: 15px; background-color: var(--card-bg); padding: 5px; border: 1px solid var(--border-color);}
1041
+ .grid-item h3 { color: var(--primary-color); margin-top: 0; font-size: 1.6em; margin-bottom: 10px; font-family: var(--heading-font); }
1042
+ .grid-item p { font-size: 1em; color: #666; margin-bottom: 10px; text-align: center; }
1043
+ .grid-item .price { font-size: 1.4em; font-weight: 700; color: var(--secondary-color); margin-top: 10px; }
1044
+
1045
+ /* Features Section */
1046
+ .feature-icon { font-size: 3.5em; color: var(--secondary-color); margin-bottom: 15px; }
1047
+ .feature-item h3 { font-size: 1.4em; }
1048
+
1049
+ /* Testimonials Section */
1050
+ .testimonial-item { background-color: var(--light-bg); border: 1px solid var(--primary-color); border-radius: 12px; padding: 25px; text-align: center; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
1051
+ .testimonial-item img { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; margin-bottom: 15px; border: 3px solid var(--primary-color); }
1052
+ .testimonial-item p { font-style: italic; color: #555; margin-bottom: 10px; }
1053
+ .testimonial-item .author { font-weight: 600; color: var(--primary-color); }
1054
+ .testimonial-item .role { font-size: 0.9em; color: #888; }
1055
+
1056
+ /* Pricing Section */
1057
+ .pricing-plan { background-color: var(--light-bg); border: 1px solid var(--border-color); border-radius: 12px; padding: 30px; text-align: center; box-shadow: 0 4px 15px rgba(0,0,0,0.08); }
1058
+ .pricing-plan.highlight { border-color: var(--primary-color); background-color: lighten(var(--primary-color), 40%); }
1059
+ .pricing-plan h3 { font-size: 2em; color: var(--primary-color); margin-bottom: 15px; }
1060
+ .pricing-plan .price { font-size: 3em; font-weight: 700; color: var(--secondary-color); margin-bottom: 20px; }
1061
+ .pricing-plan ul { list-style: none; padding: 0; margin-bottom: 30px; }
1062
+ .pricing-plan ul li { margin-bottom: 10px; font-size: 1.1em; color: #555; }
1063
+ .pricing-plan .cta-button { display: inline-block; padding: 12px 25px; background-color: var(--primary-color); color: white; text-decoration: none; border-radius: 50px; font-weight: 600; transition: background-color 0.3s ease; }
1064
+ .pricing-plan .cta-button:hover { background-color: darken(var(--primary-color), 10%); }
1065
+
1066
+ /* Contact Section */
1067
+ .contact-section { text-align: center; }
1068
+ .contact-info p { font-size: 1.1em; margin-bottom: 10px; text-align: center;}
1069
+ .contact-info a { color: var(--primary-color); text-decoration: none; }
1070
+ .contact-info a:hover { text-decoration: underline; }
1071
+ .contact-form { margin-top: 30px; max-width: 600px; margin-left: auto; margin-right: auto; text-align: left; }
1072
+ .contact-form label { display: block; font-weight: 600; margin-bottom: 8px; color: var(--primary-color); }
1073
+ .contact-form input[type="text"],
1074
+ .contact-form input[type="email"],
1075
+ .contact-form textarea {
1076
+ width: 100%; padding: 12px; margin-bottom: 20px; border: 1px solid var(--border-color); border-radius: 8px;
1077
+ font-size: 1em; box-sizing: border-box; transition: border-color 0.3s ease;
1078
+ }
1079
+ .contact-form input[type="text"]:focus,
1080
+ .contact-form input[type="email"]:focus,
1081
+ .contact-form textarea:focus {
1082
+ border-color: var(--primary-color); outline: none; box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.1);
1083
+ }
1084
+ .contact-form textarea { resize: vertical; min-height: 120px; }
1085
+ .contact-form button {
1086
+ display: inline-block; padding: 15px 30px; background-color: var(--secondary-color); color: white;
1087
+ border: none; border-radius: 50px; font-size: 1.2em; font-weight: 600; cursor: pointer;
1088
+ transition: background-color 0.3s ease, transform 0.2s ease;
1089
+ }
1090
+ .contact-form button:hover { background-color: darken(var(--secondary-color), 10%); transform: translateY(-2px); }
1091
+
1092
+ /* Footer */
1093
+ footer { background-color: #333; color: white; text-align: center; padding: 25px; font-size: 0.9em; margin-top: 30px; }
1094
+
1095
  @media (max-width: 768px) {
1096
+ .container { margin: 15px auto; border-radius: 5px; }
1097
+ header { padding: 40px 15px; }
1098
+ header h1 { font-size: 2.5em; }
1099
+ header p { font-size: 1.1em; }
1100
+ .section-content { padding: 25px 15px; }
1101
+ .section-content h2 { font-size: 2em; }
1102
+ .hero-section { padding: 50px 15px; }
1103
+ .hero-section h1 { font-size: 2.8em; }
1104
+ .hero-section p { font-size: 1.2em; }
1105
+ .hero-section .cta-button { font-size: 1em; padding: 12px 25px; }
1106
+ .grid-container { grid-template-columns: 1fr; gap: 20px; }
1107
+ .grid-item { padding: 20px; }
1108
+ .contact-form { padding: 0 15px; }
1109
  }
1110
  </style>
1111
  </head>
1112
  <body>
1113
  <div class="container">
1114
+ {% if site_data.main_heading or site_data.tagline %}
1115
  <header>
1116
  <h1>{{ site_data.main_heading | default('Добро пожаловать!') }}</h1>
1117
  <p>{{ site_data.tagline | default('Ваш сгенерированный сайт готов.') }}</p>
1118
  </header>
1119
+ {% endif %}
1120
 
1121
  {% for section in site_data.sections %}
1122
  <div class="section-content">
1123
  <h2>{{ section.title }}</h2>
1124
+ {% if section.description %}<p>{{ section.description | replace('\\n', '<br>') | safe }}</p>{% endif %}
1125
+
1126
+ {% if section.type == 'hero' %}
1127
+ <div class="hero-section" style="background-color: var(--primary-color); color: white;">
1128
+ <h1>{{ section.title }}</h1>
1129
+ <p>{{ section.subtitle | replace('\\n', '<br>') | safe }}</p>
1130
+ {% if section.cta_text and section.cta_link %}
1131
+ <a href="{{ section.cta_link }}" class="cta-button">{{ section.cta_text }}</a>
1132
+ {% endif %}
1133
+ {% if section.image_url %}<img src="{{ section.image_url }}" alt="{{ section.title }}">{% endif %}
1134
+ </div>
1135
+ {% elif section.type == 'text' %}
1136
  <div class="text-content">
1137
  <p>{{ section.content | replace('\\n', '<br>') | safe }}</p>
1138
  </div>
1139
  {% elif section.type == 'list' and site_data.data_items %}
1140
+ <div class="grid-container">
1141
  {% for item in site_data.data_items %}
1142
+ {% if not section.filter_category or item.category == section.filter_category %}
1143
+ <div class="grid-item">
1144
  {% if item.image_url %}<img src="{{ item.image_url }}" alt="{{ item.name }}">{% endif %}
1145
  <h3>{{ item.name }}</h3>
1146
  <p>{{ item.description | default('') | replace('\\n', '<br>') | safe }}</p>
1147
  {% if item.price %}<div class="price">{{ "%.2f"|format(item.price) }}</div>{% endif %}
1148
+ {% if item.category %}<p style="font-size: 0.9em; color: #999;">Категория: {{ item.category }}</p>{% endif %}
1149
  {% for key, value in item.fields.items() %}
1150
  <p style="font-size: 0.85em; color: #555;"><strong>{{ key|capitalize }}:</strong> {{ value }}</p>
1151
  {% endfor %}
1152
  </div>
1153
+ {% endif %}
1154
  {% endfor %}
1155
  </div>
1156
+ {% elif section.type == 'features' %}
1157
+ <div class="grid-container">
1158
+ {% for item in section.items if section.items else site_data.data_items %}
1159
+ {% if item.icon or item.name or item.description %}
1160
+ <div class="grid-item feature-item">
1161
+ {% if item.icon %}<i class="{{ item.icon }} feature-icon"></i>{% endif %}
1162
+ <h3>{{ item.name }}</h3>
1163
+ <p>{{ item.description | default('') | replace('\\n', '<br>') | safe }}</p>
1164
+ </div>
1165
+ {% endif %}
1166
+ {% endfor %}
1167
  </div>
1168
+ {% elif section.type == 'testimonials' %}
1169
+ <div class="grid-container">
1170
+ {% for item in site_data.data_items %}
1171
+ {% if item.author and item.description %}
1172
+ <div class="grid-item testimonial-item">
1173
+ {% if item.image_url %}<img src="{{ item.image_url }}" alt="{{ item.author }}">{% endif %}
1174
+ <p>"{{ item.description | replace('\\n', '<br>') | safe }}"</p>
1175
+ <div class="author">{{ item.author }}</div>
1176
+ {% if item.role %}<div class="role">{{ item.role }}</div>{% endif %}
1177
+ </div>
1178
+ {% endif %}
1179
+ {% endfor %}
1180
+ </div>
1181
+ {% elif section.type == 'pricing' %}
1182
+ <div class="grid-container">
1183
+ {% for item in site_data.data_items %}
1184
+ {% if item.name and item.price %}
1185
+ <div class="grid-item pricing-plan">
1186
+ <h3>{{ item.name }}</h3>
1187
+ <div class="price">{{ "%.2f"|format(item.price) }}</div>
1188
+ {% if item.description %}
1189
+ <ul>
1190
+ {% for feature in item.description.split('\\n') %}
1191
+ <li>{{ feature }}</li>
1192
+ {% endfor %}
1193
+ </ul>
1194
+ {% endif %}
1195
+ <a href="#" class="cta-button">Выбрать план</a>
1196
+ </div>
1197
+ {% endif %}
1198
+ {% endfor %}
1199
+ </div>
1200
+ {% elif section.type == 'contact' %}
1201
+ <div class="contact-section">
1202
+ {% if section.content %}<p>{{ section.content | replace('\\n', '<br>') | safe }}</p>{% endif %}
1203
+ <div class="contact-info">
1204
+ {% if section.email %}<p><strong>Email:</strong> <a href="mailto:{{ section.email }}">{{ section.email }}</a></p>{% endif %}
1205
+ {% if section.phone %}<p><strong>Телефон:</strong> <a href="tel:{{ section.phone }}">{{ section.phone }}</a></p>{% endif %}
1206
+ {% if section.address %}<p><strong>Адрес:</strong> {{ section.address }}</p>{% endif %}
1207
+ </div>
1208
+ {% if section.form_fields %}
1209
+ <div class="contact-form">
1210
+ <form action="#" method="POST" onsubmit="alert('Эта форма только для демонстрации. Отправка данных не реализована.'); return false;">
1211
+ {% for field in section.form_fields %}
1212
+ <label for="field-{{ loop.index }}">{{ field.label }}:</label>
1213
+ {% if field.type == 'textarea' %}
1214
+ <textarea id="field-{{ loop.index }}" name="{{ field.label | lower | replace(' ', '_') }}" placeholder="{{ field.label }}" required></textarea>
1215
+ {% else %}
1216
+ <input type="{{ field.type }}" id="field-{{ loop.index }}" name="{{ field.label | lower | replace(' ', '_') }}" placeholder="{{ field.label }}" required>
1217
+ {% endif %}
1218
+ {% endfor %}
1219
+ <button type="submit">Отправить сообщение</button>
1220
+ </form>
1221
  </div>
 
 
 
1222
  {% endif %}
1223
  </div>
1224
+ {% elif section.type == 'image_gallery' and section.images %}
1225
+ <div class="grid-container">
1226
+ {% for image_url in section.images %}
1227
+ <div class="grid-item">
1228
+ <img src="{{ image_url }}" alt="Галерея">
1229
+ </div>
1230
+ {% endfor %}
1231
+ </div>
1232
  {% endif %}
1233
  </div>
1234
  {% endfor %}
1235
 
1236
+ {% if site_data.footer_text %}
1237
  <footer>
1238
  <p>{{ site_data.footer_text | default(site_data.site_title + ' © ' + now.year|string + '. Все права защищены.') }}</p>
1239
  </footer>
1240
+ {% endif %}
1241
  </div>
1242
  </body>
1243
  </html>
1244
  """
1245
 
 
 
1246
  @app.route('/')
1247
  def index():
 
 
 
1248
  generated_sites = load_site_metadata()
 
1249
  sorted_sites = dict(sorted(generated_sites.items(), key=lambda item: item[1].get('timestamp', ''), reverse=True))
1250
  return render_template_string(index_page_template, generated_sites=sorted_sites)
1251
 
1252
  @app.route('/generate', methods=['POST'])
1253
  def handle_generate():
 
 
 
 
1254
  if 'prompt' not in request.form:
1255
+ flash("Текстовый запрос (промпт) не найден.", 'error')
1256
  return jsonify({"error": "Текстовый запрос (промпт) не найден."}), 400
 
1257
  user_prompt = request.form['prompt']
 
1258
  if not user_prompt or not user_prompt.strip():
1259
+ flash("Текстовый запрос не может быть пустым.", 'error')
1260
  return jsonify({"error": "Текстовый запрос не может быть пустым."}), 400
 
1261
  try:
 
1262
  site_data_json = generate_site_json_from_prompt(user_prompt)
 
1263
  if not site_data_json:
1264
+ flash("Сгенерированные данные сайта пусты.", 'error')
1265
  return jsonify({"error": "Сгенерированные данные сайта пусты."}), 500
1266
+ site_id = str(uuid.uuid4())
 
1267
  generated_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
 
 
1268
  site_metadata = load_site_metadata()
1269
  site_metadata[site_id] = {
1270
  "timestamp": generated_at,
1271
  "ai_generated_data": site_data_json
1272
  }
1273
  save_site_metadata(site_metadata)
 
 
1274
  site_url = url_for('serve_generated_site', site_id=site_id)
1275
+ flash("Сайт успешно сгенерирован!", 'success')
1276
  return jsonify({"site_url": site_url})
 
1277
  except ValueError as ve:
1278
  logging.error(f"Ошибка генерации (ValueError): {ve}")
1279
+ flash(f"Ошибка генерации: {str(ve)}", 'error')
1280
  return jsonify({"error": str(ve)}), 400
1281
  except Exception as e:
1282
  logging.error(f"Неожиданная ошибка во время генерации сайта: {e}", exc_info=True)
1283
+ flash(f"Внутренняя ошибка сервера при генерации сайта: {e}", 'error')
1284
  return jsonify({"error": f"Внутренняя ошибка сервера при генерации сайта: {e}"}), 500
1285
 
1286
  @app.route('/generated_site/<site_id>')
1287
  def serve_generated_site(site_id):
 
 
 
 
1288
  site_metadata = load_site_metadata()
1289
  site_info = site_metadata.get(site_id)
 
1290
  if not site_info:
1291
  flash(f"Сайт с ID '{site_id}' не найден.", 'error')
1292
  return redirect(url_for('index'))
 
1293
  site_data = site_info.get('ai_generated_data')
1294
  if not site_data:
1295
  flash(f"Данные для сайта с ID '{site_id}' повреждены.", 'error')
1296
  return redirect(url_for('index'))
 
 
1297
  return render_template_string(dynamic_site_template, site_data=site_data, now=datetime.now())
1298
 
1299
  @app.route('/delete_site', methods=['POST'])
1300
  def delete_site():
 
 
 
1301
  site_id_to_delete = request.form.get('site_id')
1302
  if not site_id_to_delete:
1303
  flash("ID сайта для удаления не предоставлен.", 'error')
1304
  return redirect(url_for('index'))
 
1305
  site_metadata = load_site_metadata()
1306
  if site_id_to_delete in site_metadata:
1307
  del site_metadata[site_id_to_delete]
1308
+ save_site_metadata(site_metadata)
1309
  flash(f"Сайт с ID '{site_id_to_delete}' успешно удален.", 'success')
1310
  else:
1311
  flash(f"Сайт с ID '{site_id_to_delete}' не найден.", 'warning')
 
1312
  return redirect(url_for('index'))
1313
 
 
 
1314
  if __name__ == '__main__':
1315
  logging.info("Приложение запускается. Выполняется первоначальная загрузка/скачивание данных...")
1316
+ download_db_from_hf()
1317
+ load_site_metadata()
1318
  logging.info("Первоначальная загрузка данных завершена.")
1319
 
1320
  if API_KEY_INTERNAL is None: