diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7a60b85
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+*.pyc
diff --git a/AGENTS.md b/AGENTS.md
index 6a45b3e..38899a5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,17 +95,6 @@ Workspace юзера `orlovskiy_r`. Это **учебная среда**, где
---
-## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда)
-
-1. **Только статика — HTML + CSS + JS в браузере.**
-2. **Никакого бэкенда.** Никаких Node/Express/FastAPI/Django/PHP/Go-серверов. Никаких БД. Никакого Redis.
-3. **Никакой аутентификации / OAuth / JWT.**
-4. **Никакого Docker, nginx, sudo, системных настроек.**
-5. **Никаких тяжёлых сборщиков** (`npm install` дерево на 500МБ). Tailwind — только через CDN.
-6. **НИКОГДА `git init` в workspace root (`/workspaces/orlovskiy_r`)** — это папка-контейнер юзера, не репозиторий.
-
----
-
## ✅ ВСЕГДА работай через `./new-project`
Если юзер сказал «сделай сайт NAME» / «создай проект NAME»:
@@ -151,23 +140,6 @@ git push origin HEAD:pages
---
-## ❌ Чего НЕ делать НИКОГДА
-
-- ❌ `git init` в workspace root
-- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt)
-- ❌ Создавать `server.js` / `app.py` / `main.go` как backend
-- ❌ Использовать `gh` CLI или GitHub API
-- ❌ Вызывать Gitea Pages-API (его нет)
-- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages»
-- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено
-- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз)
-- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано
-- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML
-- ❌ Говорить «это сложно» — переформулируй в простое
-- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй
-
----
-
## 🎨 design.md
Рядом лежит `design.md` с готовой палитрой, типографикой и стартер-шаблоном `index.html`. **Начинай с него.** Не выдумывай новые цвета — модифицируй существующие.
diff --git a/index.html b/index.html
index 2b4ab0f..94fef34 100644
--- a/index.html
+++ b/index.html
@@ -69,6 +69,11 @@
Генератор кликбейта
Введите начало заголовка — модель продолжит в стиле кликбейта.
+
+
+
+ ⏳ Проверка...
+
diff --git a/script.js b/script.js
index 51c3de9..a97d6b0 100644
--- a/script.js
+++ b/script.js
@@ -1,6 +1,14 @@
-// Настройка: если запустили server.py на другом хосте/порте,
-// укажите URL здесь или добавьте ?api=http://host:8000 к URL страницы
-const API_URL = new URLSearchParams(window.location.search).get('api') || '';
+// API URL: ?api=... в URL, или localStorage, или auto-detect (same host :8000)
+function getApiUrl() {
+ const qs = new URLSearchParams(window.location.search).get('api');
+ if (qs) return qs.replace(/\/+$/, '');
+ const saved = localStorage.getItem('cbgen_api_url');
+ if (saved) return saved.replace(/\/+$/, '');
+ // Auto-detect: same hostname, port 8000
+ return `${location.protocol}//${location.hostname}:8000`;
+}
+
+let API_URL = getApiUrl();
const promptInput = document.getElementById('promptInput');
const tempSlider = document.getElementById('tempSlider');
@@ -11,6 +19,50 @@ const generateBtn = document.getElementById('generateBtn');
const generate5Btn = document.getElementById('generate5Btn');
const outputArea = document.getElementById('outputArea');
const statusMsg = document.getElementById('statusMsg');
+const apiInput = document.getElementById('apiUrlInput');
+const apiStatus = document.getElementById('apiStatus');
+
+let backendOnline = false;
+
+async function checkBackend() {
+ try {
+ const res = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) });
+ if (res.ok) {
+ const data = await res.json();
+ backendOnline = data.model_loaded === true;
+ if (backendOnline && data.using_dummy_vocab) {
+ apiStatus.textContent = '🟡 Модель загружена (без vocab.pt)';
+ apiStatus.className = 'api-status warn';
+ } else if (backendOnline) {
+ apiStatus.textContent = '🟢 Модель загружена';
+ apiStatus.className = 'api-status ok';
+ } else {
+ apiStatus.textContent = '🟡 Сервер работает, модель не загружена';
+ apiStatus.className = 'api-status warn';
+ }
+ } else {
+ backendOnline = false;
+ apiStatus.textContent = '🔴 Сервер отвечает с ошибкой';
+ apiStatus.className = 'api-status err';
+ }
+ } catch {
+ backendOnline = false;
+ apiStatus.textContent = '🔴 Бэкенд недоступен — используются шаблоны';
+ apiStatus.className = 'api-status err';
+ }
+}
+
+if (apiInput) {
+ apiInput.value = API_URL;
+ apiInput.addEventListener('change', () => {
+ API_URL = apiInput.value.replace(/\/+$/, '');
+ localStorage.setItem('cbgen_api_url', API_URL);
+ checkBackend();
+ });
+}
+
+checkBackend();
+setInterval(checkBackend, 15000);
tempSlider.addEventListener('input', () => { tempVal.textContent = tempSlider.value; });
topkSlider.addEventListener('input', () => { topkVal.textContent = topkSlider.value; });
@@ -83,25 +135,24 @@ async function generate(count) {
statusMsg.className = 'status-msg';
let texts;
+ let usedBackend = false;
- if (API_URL) {
- try {
- const res = await fetch(`${API_URL}/generate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ prompt, temperature, top_k, num_samples: count })
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json();
- texts = data.texts;
- } catch {
- texts = localGenerate(prompt, temperature, top_k, count);
- }
- } else {
+ try {
+ const res = await fetch(`${API_URL}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ prompt, temperature, top_k, num_samples: count })
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ texts = data.texts;
+ usedBackend = true;
+ } catch {
texts = localGenerate(prompt, temperature, top_k, count);
+ usedBackend = false;
}
- statusMsg.textContent = '';
+ statusMsg.textContent = usedBackend ? '✅ Модель (PyTorch)' : '⚡ Локальные шаблоны (бэкенд недоступен)';
statusMsg.className = 'status-msg';
texts.forEach((text, i) => {
diff --git a/server.py b/server.py
index 3fc9275..853cc09 100644
--- a/server.py
+++ b/server.py
@@ -3,30 +3,31 @@ import torch.nn as nn
import torch.nn.functional as F
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.staticfiles import StaticFiles
+from fastapi.responses import FileResponse
from pydantic import BaseModel
-import os
+import os, pickle, sys
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
MODEL_PATH = os.environ.get("MODEL_PATH", "best_generator.pt")
VOCAB_PATH = os.environ.get("VOCAB_PATH", "vocab.pt")
+MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "7"))
# ---------------------------------------------------------------------------
# Model architecture
# ---------------------------------------------------------------------------
class RotaryPositionalEmbedding(nn.Module):
- def __init__(self, dim=256, max_seq_len=512):
+ def __init__(self, dim=32):
super().__init__()
- inv_freq = 1.0 / (10000 ** (torch.arange(0, dim, 2).float() / dim))
+ inv_freq = 1.0 / (10000 ** (torch.arange(0, dim).float() / dim))
self.register_buffer("inv_freq", inv_freq)
- self.max_seq_len = max_seq_len
def forward(self, x):
seq_len = x.shape[1]
t = torch.arange(seq_len, device=x.device).float()
freqs = torch.einsum("i,j->ij", t, self.inv_freq)
- emb = torch.cat((freqs, freqs), dim=-1)
- return emb[:seq_len]
+ return freqs[:seq_len]
class MultiHeadAttentionWithRoPE(nn.Module):
@@ -45,13 +46,13 @@ class MultiHeadAttentionWithRoPE(nn.Module):
self.rope = RotaryPositionalEmbedding(dim=self.d_head)
self.dropout = nn.Dropout(dropout)
- def _apply_rope(self, x, freqs):
- return x * freqs.cos() + self._rotate_half(x) * freqs.sin()
-
def _rotate_half(self, x):
x1, x2 = x.chunk(2, dim=-1)
return torch.cat((-x2, x1), dim=-1)
+ def _apply_rope(self, x, freqs):
+ return x * freqs.cos() + self._rotate_half(x) * freqs.sin()
+
def forward(self, x, mask=None):
B, T, C = x.shape
Q = self.W_q(x).view(B, T, self.n_heads, self.d_head).transpose(1, 2)
@@ -100,7 +101,7 @@ class DecoderBlockWithRoPE(nn.Module):
class GPTModelWithRoPE(nn.Module):
def __init__(self, vocab_size=42962, d_model=256, n_heads=8,
- n_layers=3, d_ff=1024, dropout=0.1, max_seq_len=512):
+ n_layers=3, d_ff=1024, dropout=0.1):
super().__init__()
self.token_embedding = nn.Embedding(vocab_size, d_model)
self.embedding_dropout = nn.Dropout(dropout)
@@ -123,13 +124,20 @@ class GPTModelWithRoPE(nn.Module):
def generate(self, input_ids, max_new_tokens=7, temperature=1.0,
top_k=50, top_p=0.9, eos_token_id=None):
self.eval()
+ B, T = input_ids.shape
+ # Causal mask for current sequence length
+ mask = torch.tril(torch.ones(1, 1, T, T, device=input_ids.device))
+
for _ in range(max_new_tokens):
- logits = self(input_ids)
+ logits = self(input_ids, mask)
logits = logits[:, -1, :]
if temperature > 0:
logits = logits / temperature
+ # Replace nan/inf with -inf to avoid crashes
+ logits = torch.nan_to_num(logits, nan=float("-inf"), posinf=float("-inf"), neginf=float("-inf"))
+
if top_k > 0:
values, _ = torch.topk(logits, top_k, dim=-1)
logits[logits < values[:, -1:]] = float("-inf")
@@ -143,7 +151,11 @@ class GPTModelWithRoPE(nn.Module):
probs = F.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
+
+ # Update causal mask for new token
input_ids = torch.cat([input_ids, next_token], dim=-1)
+ new_T = input_ids.shape[1]
+ mask = torch.tril(torch.ones(1, 1, new_T, new_T, device=input_ids.device))
if eos_token_id is not None and next_token.item() == eos_token_id:
break
@@ -151,6 +163,27 @@ class GPTModelWithRoPE(nn.Module):
return input_ids
+# ---------------------------------------------------------------------------
+# Vocab (in case no vocab file exists)
+# ---------------------------------------------------------------------------
+
+class DummyVocab:
+ """Minimal vocab that passes through token IDs as text."""
+ word2idx = {'': 0, '': 1, '': 2, '': 3}
+ idx2word = {0: '', 1: '', 2: '', 3: ''}
+
+ def __init__(self):
+ for i in range(4, 42962):
+ self.word2idx[f''] = i
+ self.idx2word[i] = f''
+
+ def text_to_indices_for_generator(self, text):
+ return [self.word2idx.get(w, 3) for w in text.strip().split()]
+
+ def indices_to_text(self, indices):
+ return ' '.join(self.idx2word.get(i, '') for i in indices)
+
+
# ---------------------------------------------------------------------------
# Generation function
# ---------------------------------------------------------------------------
@@ -172,10 +205,16 @@ def generate_clickbait(model, vocab, prompt="", max_new_tokens=7,
temperature=temperature,
top_k=top_k,
top_p=top_p,
- eos_token_id=vocab.word2idx['']
+ eos_token_id=vocab.word2idx.get('', 1)
)
generated_text = vocab.indices_to_text(generated[0].tolist())
+
+ # Clean up special tokens for display
+ for tok in ['', '', '', '']:
+ generated_text = generated_text.replace(tok, '')
+ generated_text = generated_text.strip()
+
return generated_text
@@ -191,50 +230,67 @@ app.add_middleware(
allow_headers=["*"],
)
+
class GenerateRequest(BaseModel):
prompt: str = ""
temperature: float = 1.0
top_k: int = 50
num_samples: int = 1
+
class GenerateResponse(BaseModel):
texts: list[str]
+
model = None
vocab = None
+using_dummy_vocab = False
@app.on_event("startup")
def load_artifacts():
- global model, vocab
+ global model, vocab, using_dummy_vocab
if not os.path.exists(MODEL_PATH):
print(f"[WARN] {MODEL_PATH} not found. Model loading skipped.")
return
- if not os.path.exists(VOCAB_PATH):
- print(f"[WARN] {VOCAB_PATH} not found. Vocab loading skipped.")
- return
try:
- import pickle
checkpoint = torch.load(MODEL_PATH, map_location=DEVICE, weights_only=True)
- vocab = pickle.load(open(VOCAB_PATH, "rb"))
-
- model = GPTModelWithRoPE(
- vocab_size=len(vocab.word2idx) if hasattr(vocab, 'word2idx') else 42962
- ).to(DEVICE)
if isinstance(checkpoint, dict) and 'model_state_dict' in checkpoint:
- model.load_state_dict(checkpoint['model_state_dict'])
+ state_dict = checkpoint['model_state_dict']
else:
- model.load_state_dict(checkpoint)
+ state_dict = checkpoint
+
+ vocab_size = state_dict['token_embedding.weight'].shape[0]
+ model = GPTModelWithRoPE(vocab_size=vocab_size).to(DEVICE)
+
+ # Remove causal_mask (we compute it dynamically)
+ sd_clean = {k: v for k, v in state_dict.items() if k != 'causal_mask'}
+ missing, unexpected = model.load_state_dict(sd_clean, strict=False)
+ if missing:
+ print(f"[WARN] Missing keys: {missing}")
+ if unexpected:
+ print(f"[WARN] Unexpected keys: {unexpected}")
model.eval()
- print(f"[OK] Model loaded on {DEVICE}")
- print(f"[OK] Vocab size: {len(vocab.word2idx)}")
+
+ # Load vocab
+ if os.path.exists(VOCAB_PATH):
+ vocab = pickle.load(open(VOCAB_PATH, "rb"))
+ using_dummy_vocab = False
+ print(f"[OK] Vocab loaded: {len(vocab.word2idx)} tokens")
+ else:
+ print(f"[WARN] {VOCAB_PATH} not found, using fallback dummy vocab")
+ vocab = DummyVocab()
+ using_dummy_vocab = True
+
+ print(f"[OK] Model loaded on {DEVICE} | params: {sum(p.numel() for p in model.parameters()):,}")
+
except Exception as e:
- print(f"[ERROR] Failed to load artifacts: {e}")
- model = None
- vocab = None
+ print(f"[ERROR] Failed to load model: {e}")
+ import traceback
+ traceback.print_exc()
@app.get("/api/health")
@@ -243,28 +299,55 @@ def health():
"status": "ok",
"model_loaded": model is not None,
"vocab_loaded": vocab is not None,
+ "using_dummy_vocab": using_dummy_vocab,
"device": str(DEVICE),
+ "vocab_size": len(vocab.word2idx) if vocab else 0,
+ "note": "vocab.pt not found — output shows token IDs" if using_dummy_vocab else "ready",
}
@app.post("/api/generate", response_model=GenerateResponse)
def generate(req: GenerateRequest):
- if model is None or vocab is None:
- raise HTTPException(status_code=503, detail="Model or vocab not loaded")
+ if model is None:
+ raise HTTPException(status_code=503, detail="Model not loaded")
+
+ # Clamp / validate
+ temperature = max(0.1, min(3.0, req.temperature))
+ top_k = max(1, min(200, req.top_k))
+ count = max(1, min(20, req.num_samples))
texts = []
- for _ in range(req.num_samples):
+ for _ in range(count):
text = generate_clickbait(
- model, vocab,
+ model, vocab or DummyVocab(),
prompt=req.prompt,
- temperature=req.temperature,
- top_k=req.top_k,
+ temperature=temperature,
+ top_k=top_k,
+ max_new_tokens=MAX_NEW_TOKENS,
)
texts.append(text)
return GenerateResponse(texts=texts)
+# ---------------------------------------------------------------------------
+# Static files + SPA fallback
+# ---------------------------------------------------------------------------
+
+STATIC_DIR = os.path.dirname(os.path.abspath(__file__))
+
+@app.get("/")
+def serve_index():
+ return FileResponse(os.path.join(STATIC_DIR, "index.html"))
+
+@app.get("/{path:path}")
+def serve_static(path: str):
+ file_path = os.path.join(STATIC_DIR, path)
+ if os.path.isfile(file_path):
+ return FileResponse(file_path)
+ return FileResponse(os.path.join(STATIC_DIR, "index.html"))
+
+
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
diff --git a/style.css b/style.css
index 09845f8..a810f53 100644
--- a/style.css
+++ b/style.css
@@ -258,6 +258,50 @@ body {
max-width: 700px;
}
+.api-config {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid rgba(255,255,255,0.08);
+}
+
+.api-label {
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--gray-500);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ white-space: nowrap;
+}
+
+.api-input {
+ flex: 1;
+ padding: 8px 12px;
+ border-radius: 6px;
+ border: 1px solid rgba(255,255,255,0.12);
+ background: var(--gray-900);
+ color: var(--white);
+ font-size: 13px;
+ font-family: "SF Mono", "Fira Code", Menlo, Consolas, monospace;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.api-input:focus {
+ border-color: var(--cyan);
+}
+
+.api-status {
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.api-status.ok { color: #50fa7b; }
+.api-status.warn { color: #f1fa8c; }
+.api-status.err { color: #ff6b6b; }
+
.gen-row {
margin-bottom: 20px;
}
@@ -524,6 +568,15 @@ body {
gap: 16px;
}
+ .api-config {
+ flex-wrap: wrap;
+ }
+
+ .api-status {
+ width: 100%;
+ margin-top: -4px;
+ }
+
.nav-links {
display: none;
}