diff --git a/server/index.js b/server/index.js index 03578e2..c8b0cdf 100644 --- a/server/index.js +++ b/server/index.js @@ -602,6 +602,111 @@ app.delete("/api/characters/:id", requireAuth, async (req, res) => { } }); +// ============ NPC CHARACTERS ROUTES ============ + +// Получить всех NPC пользователя +app.get("/api/npc", requireAuth, async (req, res) => { + try { + const npcCharacters = db.collection("npc_characters"); + const userNPCs = await npcCharacters + .find({ userId: req.session.userId }) + .sort({ updatedAt: -1 }) + .toArray(); + + res.json(userNPCs); + } catch (error) { + console.error("Get NPCs error:", error); + res.status(500).json({ error: "Failed to get NPCs" }); + } +}); + +// Получить одного NPC +app.get("/api/npc/:id", requireAuth, async (req, res) => { + try { + const npcCharacters = db.collection("npc_characters"); + const npc = await npcCharacters.findOne({ + _id: new ObjectId(req.params.id), + userId: req.session.userId, + }); + + if (!npc) { + return res.status(404).json({ error: "NPC not found" }); + } + + res.json(npc); + } catch (error) { + console.error("Get NPC error:", error); + res.status(500).json({ error: "Failed to get NPC" }); + } +}); + +// Создать NPC +app.post("/api/npc", requireAuth, async (req, res) => { + try { + const npcCharacters = db.collection("npc_characters"); + const newNPC = { + ...req.body, + userId: req.session.userId, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const result = await npcCharacters.insertOne(newNPC); + res.json({ ...newNPC, _id: result.insertedId }); + } catch (error) { + console.error("Create NPC error:", error); + res.status(500).json({ error: "Failed to create NPC" }); + } +}); + +// Обновить NPC +app.put("/api/npc/:id", requireAuth, async (req, res) => { + try { + const npcCharacters = db.collection("npc_characters"); + const result = await npcCharacters.updateOne( + { + _id: new ObjectId(req.params.id), + userId: req.session.userId, + }, + { + $set: { + ...req.body, + updatedAt: new Date(), + }, + }, + ); + + if (result.matchedCount === 0) { + return res.status(404).json({ error: "NPC not found" }); + } + + res.json({ success: true }); + } catch (error) { + console.error("Update NPC error:", error); + res.status(500).json({ error: "Failed to update NPC" }); + } +}); + +// Удалить NPC +app.delete("/api/npc/:id", requireAuth, async (req, res) => { + try { + const npcCharacters = db.collection("npc_characters"); + const result = await npcCharacters.deleteOne({ + _id: new ObjectId(req.params.id), + userId: req.session.userId, + }); + + if (result.deletedCount === 0) { + return res.status(404).json({ error: "NPC not found" }); + } + + res.json({ success: true }); + } catch (error) { + console.error("Delete NPC error:", error); + res.status(500).json({ error: "Failed to delete NPC" }); + } +}); + // ============ ADMIN STATS ============ // Получить статистику по всем историям и токенам @@ -701,6 +806,130 @@ app.get("/api/admin/stats", requireAuth, async (req, res) => { } }); +// ============ IMAGE GENERATION ============ + +// Прокси для генерации изображений через Grok (обход CORS) +app.post("/api/generate-image", requireAuth, async (req, res) => { + try { + const { prompt } = req.body; + const apiKey = process.env.GEMINIGEN_API_KEY; + + if (!apiKey) { + return res + .status(500) + .json({ error: "GeminiGen API key not configured" }); + } + + console.log("Generating image with Grok, prompt:", prompt); + + // Используем FormData для multipart/form-data + const formData = new FormData(); + formData.append("prompt", prompt); + formData.append("orientation", "portrait"); // 9:16 + formData.append("num_result", "1"); + + const response = await fetch( + "https://api.geminigen.ai/uapi/v1/imagen/grok", + { + method: "POST", + headers: { + "x-api-key": apiKey, + }, + body: formData, + }, + ); + + if (!response.ok) { + const error = await response.text(); + console.error("Grok API error:", error); + return res + .status(response.status) + .json({ error: "Image generation failed", details: error }); + } + + const data = await response.json(); + console.log("Grok response:", data); + + // Проверяем статус генерации + if (data.status === 2 && data.generate_result) { + // Готово - возвращаем URL + res.json({ url: data.generate_result }); + } else if (data.status === 1) { + // В процессе - возвращаем uuid для polling + res.json({ + pending: true, + uuid: data.uuid, + status_percentage: data.status_percentage, + }); + } else { + res + .status(500) + .json({ error: data.error_message || "Generation failed" }); + } + } catch (error) { + console.error("Image generation error:", error); + res.status(500).json({ error: "Failed to generate image" }); + } +}); + +// Проверка статуса генерации +app.get("/api/generate-image/status/:uuid", requireAuth, async (req, res) => { + try { + const apiKey = process.env.GEMINIGEN_API_KEY; + const { uuid } = req.params; + + const response = await fetch( + `https://api.geminigen.ai/uapi/v1/history/${uuid}`, + { + headers: { + "x-api-key": apiKey, + }, + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("History API error:", response.status, errorText); + return res + .status(response.status) + .json({ error: "Failed to check status" }); + } + + const data = await response.json(); + console.log( + "History API status:", + data.status, + "images:", + data.generated_image?.length, + ); + + if (data.status === 2) { + // Completed - get image URL from generated_image array + const imageUrl = + data.generated_image?.[0]?.image_url || + data.generated_image?.[0]?.file_download_url || + data.generate_result; + if (imageUrl) { + res.json({ url: imageUrl, done: true }); + } else { + res.status(500).json({ error: "No image URL in response" }); + } + } else if (data.status === 1) { + res.json({ pending: true, status_percentage: data.status_percentage }); + } else if (data.status === 3) { + res + .status(500) + .json({ error: data.error_message || "Generation failed" }); + } else { + // Unknown status - keep polling + res.json({ pending: true, status_percentage: data.status_percentage }); + } + } catch (error) { + console.error("Status check error:", error); + res.status(500).json({ error: "Failed to check status" }); + } +}); + // Запуск сервера connectDB().then(() => { app.listen(PORT, () => { diff --git a/src/App.tsx b/src/App.tsx index 5b540af..bc29fee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import StoriesPage from "./pages/StoriesPage"; import StoryDetailPage from "./pages/StoryDetailPage"; import CreateStoryPage from "./pages/CreateStoryPage"; import CharactersPage from "./pages/CharactersPage"; +import NPCPage from "./pages/NPCPage"; import GamePage from "./pages/GamePage"; import AdminPage from "./pages/AdminPage"; import "./App.css"; @@ -24,6 +25,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6e28b52..9936663 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -26,6 +26,9 @@ export function Header() { Персонажи + + NPC + > )} diff --git a/src/pages/CreateStoryPage.css b/src/pages/CreateStoryPage.css index be62134..34aff89 100644 --- a/src/pages/CreateStoryPage.css +++ b/src/pages/CreateStoryPage.css @@ -734,6 +734,86 @@ margin-bottom: 1rem; } +.character-content { + display: flex; + gap: 1rem; +} + +.character-avatar-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.character-avatar-preview { + width: 120px; + height: 180px; + border-radius: 12px; + object-fit: cover; + object-position: top center; + border: 2px solid #667eea; + background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%); + background-size: 200% 100%; + animation: avatar-loading 1.5s infinite; +} + +.character-avatar-preview[src] { + animation: none; + background: none; +} + +@keyframes avatar-loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +.character-avatar-placeholder { + width: 120px; + height: 180px; + border-radius: 12px; + background: #1a1a1a; + border: 2px dashed #333; + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + color: #666; +} + +.btn-generate-avatar { + padding: 0.4rem 0.8rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + border-radius: 8px; + color: white; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; +} + +.btn-generate-avatar:hover:not(:disabled) { + transform: scale(1.05); + box-shadow: 0 0 10px rgba(102, 126, 234, 0.5); +} + +.btn-generate-avatar:disabled { + opacity: 0.7; + cursor: wait; +} + +.character-fields { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + .character-number { font-weight: 600; color: #667eea; @@ -823,3 +903,124 @@ font-size: 0.9rem; } } + +/* Character row with two selects */ +.character-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.character-row select { + padding: 0.5rem; + background: #0d0d0d; + border: 2px solid #333; + border-radius: 8px; + color: white; + font-size: 0.9rem; +} + +/* Character buttons */ +.character-buttons { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.add-btn.secondary { + background: transparent; + border: 2px solid #667eea; + color: #667eea; +} + +.add-btn.secondary:hover { + background: rgba(102, 126, 234, 0.1); +} + +/* NPC Selector Modal */ +.npc-selector-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.npc-selector-modal { + background: #1a1a1a; + border-radius: 16px; + padding: 1.5rem; + max-width: 700px; + width: 100%; + max-height: 80vh; + overflow-y: auto; +} + +.npc-selector-modal h3 { + margin: 0 0 1rem 0; + text-align: center; +} + +.npc-selector-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 1rem; + margin-bottom: 1rem; +} + +.npc-selector-item { + background: #0d0d0d; + border: 2px solid #333; + border-radius: 12px; + padding: 0.75rem; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.npc-selector-item:hover { + border-color: #667eea; + transform: translateY(-2px); +} + +.npc-selector-item img { + width: 100%; + height: 140px; + object-fit: cover; + object-position: top center; + border-radius: 8px; + margin-bottom: 0.5rem; +} + +.npc-selector-item .npc-placeholder { + width: 100%; + height: 140px; + background: #1a1a1a; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + margin-bottom: 0.5rem; +} + +.npc-selector-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.npc-selector-info strong { + font-size: 0.95rem; +} + +.npc-selector-info span { + font-size: 0.8rem; + color: #888; +} diff --git a/src/pages/CreateStoryPage.tsx b/src/pages/CreateStoryPage.tsx index 8f6f297..65ed834 100644 --- a/src/pages/CreateStoryPage.tsx +++ b/src/pages/CreateStoryPage.tsx @@ -2,8 +2,19 @@ import { useState, useEffect } from "react"; import { useNavigate, Link, useParams } from "react-router-dom"; import ReactMarkdown from "react-markdown"; import { useAuth } from "../contexts/AuthContext"; -import { createStory, getStory, updateStory } from "../services/api"; -import type { Character } from "../types"; +import { + createStory, + getStory, + updateStory, + getNPCCharacters, +} from "../services/api"; +import { generateAvatarUrl } from "../services/imageGen"; +import type { + Character, + CharacterAge, + CharacterGender, + NPCCharacter, +} from "../types"; import "./CreateStoryPage.css"; const GENRES = [ @@ -51,12 +62,24 @@ const CHARACTER_ROLES = [ "Союзник", "Злодей", "Наставник", - "Романтический интерес", + "Романс", "Нейтральный NPC", "Антагонист", "Комик", ]; +const CHARACTER_AGES: { value: CharacterAge; label: string }[] = [ + { value: "child", label: "Ребёнок" }, + { value: "teenager", label: "Подросток" }, + { value: "adult", label: "Взрослый" }, + { value: "elderly", label: "Пожилой" }, +]; + +const CHARACTER_GENDERS: { value: CharacterGender; label: string }[] = [ + { value: "female", label: "Женский" }, + { value: "male", label: "Мужской" }, +]; + export default function CreateStoryPage() { const { id } = useParams<{ id: string }>(); const isEditMode = Boolean(id); @@ -64,6 +87,11 @@ export default function CreateStoryPage() { const { isAuthenticated } = useAuth(); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoading, setIsLoading] = useState(isEditMode); + const [generatingAvatarIndex, setGeneratingAvatarIndex] = useState< + number | null + >(null); + const [savedNPCs, setSavedNPCs] = useState([]); + const [showNPCSelector, setShowNPCSelector] = useState(false); const [form, setForm] = useState({ title: "", @@ -80,7 +108,15 @@ export default function CreateStoryPage() { temperature: 0.9, // Креативность ИИ narrativeRules: "", // Правила повествования для ИИ // NPC персонажи мира - characters: [{ name: "", description: "", role: "Союзник" }] as Character[], + characters: [ + { + name: "", + description: "", + role: "Союзник", + age: "adult" as CharacterAge, + gender: "female" as CharacterGender, + }, + ] as Character[], // Мир worldName: "", worldDescription: "", @@ -93,6 +129,17 @@ export default function CreateStoryPage() { } }, [isAuthenticated, navigate]); + // Загрузка сохранённых NPC + useEffect(() => { + const loadSavedNPCs = async () => { + if (isAuthenticated) { + const npcs = await getNPCCharacters(); + setSavedNPCs(npcs); + } + }; + loadSavedNPCs(); + }, [isAuthenticated]); + // Загрузка истории для редактирования useEffect(() => { const loadStory = async () => { @@ -121,7 +168,15 @@ export default function CreateStoryPage() { characters: story.characters?.length > 0 ? story.characters - : [{ name: "", description: "", role: "Союзник" }], + : [ + { + name: "", + description: "", + role: "Союзник", + age: "adult" as CharacterAge, + gender: "female" as CharacterGender, + }, + ], worldName: story.world?.name || "", worldDescription: story.world?.description || "", worldRules: story.world?.rules?.length > 0 ? story.world.rules : [""], @@ -226,11 +281,35 @@ export default function CreateStoryPage() { ...prev, characters: [ ...prev.characters, - { name: "", description: "", role: "Союзник" }, + { + name: "", + description: "", + role: "Союзник", + age: "adult" as CharacterAge, + gender: "female" as CharacterGender, + }, ], })); }; + const selectSavedNPC = (npc: NPCCharacter) => { + setForm((prev) => ({ + ...prev, + characters: [ + ...prev.characters, + { + name: npc.name, + description: npc.description, + role: npc.role, + age: npc.age, + gender: npc.gender || "female", + avatarUrl: npc.avatarUrl, + }, + ], + })); + setShowNPCSelector(false); + }; + const removeCharacter = (index: number) => { setForm((prev) => ({ ...prev, @@ -238,6 +317,33 @@ export default function CreateStoryPage() { })); }; + const handleGenerateAvatar = async (index: number) => { + const char = form.characters[index]; + if (!char.name && !char.description) { + alert("Заполните имя или описание персонажа для генерации аватара"); + return; + } + + setGeneratingAvatarIndex(index); + try { + const avatarUrl = await generateAvatarUrl({ + description: char.description || char.role, + name: char.name, + age: char.age, + gender: char.gender, + }); + + const newCharacters = [...form.characters]; + newCharacters[index] = { ...newCharacters[index], avatarUrl }; + setForm((prev) => ({ ...prev, characters: newCharacters })); + } catch (error) { + console.error("Ошибка генерации аватара:", error); + alert("Ошибка при генерации аватара"); + } finally { + setGeneratingAvatarIndex(null); + } + }; + // Правила мира const handleRuleChange = (index: number, value: string) => { const newRules = [...form.worldRules]; @@ -783,42 +889,148 @@ export default function CreateStoryPage() { )} - - - handleCharacterChange(index, "name", e.target.value) - } - placeholder="Имя" - /> - - handleCharacterChange(index, "role", e.target.value) - } - > - {CHARACTER_ROLES.map((role) => ( - - {role} - - ))} - - - handleCharacterChange(index, "description", e.target.value) - } - placeholder="Описание персонажа..." - rows={2} - /> + + {/* Avatar section */} + + {char.avatarUrl ? ( + + ) : ( + 👤 + )} + handleGenerateAvatar(index)} + className="btn-generate-avatar" + disabled={generatingAvatarIndex === index} + > + {generatingAvatarIndex === index ? "⏳" : "🎨"} + + + + {/* Fields section */} + + + handleCharacterChange(index, "name", e.target.value) + } + placeholder="Имя" + /> + + + handleCharacterChange(index, "role", e.target.value) + } + > + {CHARACTER_ROLES.map((role) => ( + + {role} + + ))} + + + handleCharacterChange(index, "age", e.target.value) + } + > + {CHARACTER_AGES.map((age) => ( + + {age.label} + + ))} + + + handleCharacterChange(index, "gender", e.target.value) + } + > + {CHARACTER_GENDERS.map((g) => ( + + {g.label} + + ))} + + + + handleCharacterChange( + index, + "description", + e.target.value, + ) + } + placeholder="Описание персонажа..." + rows={2} + /> + ))} - - + Добавить персонажа - + + + + Добавить персонажа + + {savedNPCs.length > 0 && ( + setShowNPCSelector(true)} + className="add-btn secondary" + > + 📚 Выбрать из библиотеки ({savedNPCs.length}) + + )} + + + {/* NPC Selector Modal */} + {showNPCSelector && ( + setShowNPCSelector(false)} + > + e.stopPropagation()} + > + Выберите NPC из библиотеки + + {savedNPCs.map((npc) => ( + selectSavedNPC(npc)} + > + {npc.avatarUrl ? ( + + ) : ( + 👤 + )} + + {npc.name} + {npc.role} + + + ))} + + setShowNPCSelector(false)} + > + Закрыть + + + + )} {/* Submit */} diff --git a/src/pages/NPCPage.css b/src/pages/NPCPage.css new file mode 100644 index 0000000..1b37b4f --- /dev/null +++ b/src/pages/NPCPage.css @@ -0,0 +1,466 @@ +.npc-page { + max-width: 1800px; + margin: 0 auto; + padding: 2rem; +} + +.npc-header { + text-align: center; + margin-bottom: 2rem; +} + +.npc-header h1 { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.npc-header p { + color: #aaa; + margin-bottom: 1.5rem; +} + +.btn-create-npc { + padding: 0.75rem 1.5rem; + font-size: 1rem; + background: #667eea; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; +} + +.btn-create-npc:hover { + background: #5a6fd6; +} + +/* NPC Grid */ +.npc-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1rem; +} + +.npc-card { + background: #1a1a1a; + border-radius: 12px; + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; + border: 1px solid #333; + transition: + transform 0.2s, + box-shadow 0.2s; + height: 380px; +} + +.npc-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); +} + +.npc-card-avatar { + width: 100%; + height: 100%; + overflow: hidden; + background: #0d0d0d; + position: absolute; + top: 0; + left: 0; +} + +.npc-card-avatar img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: top center; +} + +.npc-avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 4rem; + background: #2a2a2a; + color: #666; +} + +.npc-card-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 0.75rem; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.9)); + z-index: 1; +} + +.npc-card-info h3 { + margin: 0 0 0.4rem 0; + font-size: 1rem; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8); +} + +.npc-role { + display: inline-block; + padding: 0.2rem 0.4rem; + background: rgba(102, 126, 234, 0.8); + color: white; + font-size: 0.65rem; + border-radius: 4px; + margin-right: 0.3rem; +} + +.npc-age { + display: inline-block; + padding: 0.2rem 0.4rem; + background: rgba(42, 42, 42, 0.8); + color: #ccc; + font-size: 0.65rem; + border-radius: 4px; +} + +.npc-nsfw { + display: inline-block; + padding: 0.2rem 0.4rem; + background: rgba(220, 53, 69, 0.9); + color: white; + font-size: 0.65rem; + border-radius: 4px; + margin-left: 0.3rem; + font-weight: bold; +} + +.npc-description { + display: none; +} + +.npc-card-actions { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: flex; + gap: 0.5rem; + opacity: 0; + transition: opacity 0.2s; +} + +.npc-card:hover .npc-card-actions { + opacity: 1; +} + +.npc-card-actions button { + width: 36px; + height: 36px; + border-radius: 8px; + border: none; + cursor: pointer; + font-size: 1rem; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s; +} + +.npc-card-actions button:hover { + transform: scale(1.1); +} + +.btn-edit { + background: #2a2a2a; +} + +.btn-delete { + background: rgba(220, 53, 69, 0.8); +} + +/* Form Modal */ +.npc-form-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.npc-form-modal { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 16px; + width: 100%; + max-width: 800px; + max-height: 90vh; + overflow-y: auto; + padding: 2rem; +} + +.npc-form-modal h2 { + margin: 0 0 1.5rem 0; + text-align: center; +} + +.form-row { + display: flex; + gap: 2rem; +} + +.form-left { + flex: 1; +} + +.form-right { + width: 280px; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: #aaa; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #333; + border-radius: 8px; + background: #0d0d0d; + color: #fff; + font-size: 1rem; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: #667eea; +} + +.form-group textarea { + resize: vertical; + min-height: 100px; +} + +.form-checkbox label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + color: #fff; +} + +.form-checkbox input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: #dc3545; + cursor: pointer; +} + +/* Avatar Section */ +.avatar-section { + text-align: center; +} + +.avatar-section label { + display: block; + margin-bottom: 0.75rem; + font-weight: 500; + color: #aaa; +} + +.avatar-preview-large { + width: fit-content; + max-width: 100%; + margin: 0 auto 1rem auto; + border-radius: 12px; + overflow: hidden; + background: transparent; + border: 2px dashed #333; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.avatar-preview-large img { + max-width: 100%; + max-height: 450px; + display: block; + border-radius: 10px; +} + +.avatar-placeholder-large { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 5rem; + color: #666; +} + +.btn-generate-avatar { + width: 100%; + padding: 0.75rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + margin-bottom: 0.75rem; + transition: opacity 0.2s; +} + +.btn-generate-avatar:hover:not(:disabled) { + opacity: 0.9; +} + +.btn-generate-avatar:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.avatar-prompt-input { + width: 100%; + padding: 0.75rem; + font-size: 0.85rem; + border: 1px solid #333; + border-radius: 6px; + background: #0d0d0d; + color: #fff; + resize: vertical; + min-height: 60px; + font-family: inherit; +} + +.avatar-prompt-input:focus { + outline: none; + border-color: #667eea; +} + +.avatar-url-input { + width: 100%; + padding: 0.5rem; + font-size: 0.85rem; + border: 1px solid #333; + border-radius: 6px; + background: #0d0d0d; + color: #fff; +} + +/* Form Actions */ +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #333; +} + +.btn-cancel { + padding: 0.75rem 1.5rem; + background: #2a2a2a; + border: 1px solid #333; + border-radius: 8px; + color: #fff; + cursor: pointer; +} + +.btn-cancel:hover { + background: #333; +} + +.btn-save { + padding: 0.75rem 1.5rem; + background: #667eea; + border: none; + border-radius: 8px; + color: white; + cursor: pointer; +} + +.btn-save:hover { + background: #5a6fd6; +} + +/* Empty & Loading States */ +.empty-state, +.loading, +.auth-required { + text-align: center; + padding: 4rem 2rem; + color: #aaa; +} + +.auth-required h2 { + margin-bottom: 1rem; +} + +/* Responsive */ +@media (max-width: 1600px) { + .npc-grid { + grid-template-columns: repeat(6, 1fr); + } +} + +@media (max-width: 1400px) { + .npc-grid { + grid-template-columns: repeat(5, 1fr); + } +} + +@media (max-width: 1100px) { + .npc-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +@media (max-width: 900px) { + .npc-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 768px) { + .form-row { + flex-direction: column; + } + + .form-right { + width: 100%; + } + + .avatar-preview-large { + height: 240px; + } + + .npc-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 500px) { + .npc-grid { + grid-template-columns: 1fr; + } + + .npc-card { + height: 320px; + } +} diff --git a/src/pages/NPCPage.tsx b/src/pages/NPCPage.tsx new file mode 100644 index 0000000..79f1d08 --- /dev/null +++ b/src/pages/NPCPage.tsx @@ -0,0 +1,372 @@ +import { useState, useEffect } from "react"; +import { useAuth } from "../contexts/AuthContext"; +import { + getNPCCharacters, + createNPCCharacter, + updateNPCCharacter, + deleteNPCCharacter, +} from "../services/api"; +import { generateAvatarUrl } from "../services/imageGen"; +import type { NPCCharacter, CharacterAge, CharacterGender } from "../types"; +import "./NPCPage.css"; + +const AGE_OPTIONS: { value: CharacterAge; label: string }[] = [ + { value: "child", label: "Ребёнок" }, + { value: "teenager", label: "Подросток" }, + { value: "adult", label: "Взрослый" }, + { value: "elderly", label: "Пожилой" }, +]; + +const ROLE_OPTIONS = [ + "Союзник", + "Злодей", + "Наставник", + "Романс", + "Нейтральный NPC", + "Антагонист", + "Комик", +]; + +const GENDER_OPTIONS: { value: CharacterGender; label: string }[] = [ + { value: "female", label: "Женский" }, + { value: "male", label: "Мужской" }, +]; + +export default function NPCPage() { + const { user } = useAuth(); + const [npcs, setNpcs] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingNpc, setEditingNpc] = useState(null); + const [generatingAvatar, setGeneratingAvatar] = useState(false); + + // Форма + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [role, setRole] = useState("Союзник"); + const [age, setAge] = useState("adult"); + const [gender, setGender] = useState("female"); + const [isNsfw, setIsNsfw] = useState(false); + const [avatarUrl, setAvatarUrl] = useState(""); + const [customPrompt, setCustomPrompt] = useState(""); + + useEffect(() => { + loadNPCs(); + }, []); + + const loadNPCs = async () => { + setLoading(true); + const data = await getNPCCharacters(); + setNpcs(data); + setLoading(false); + }; + + const resetForm = () => { + setName(""); + setDescription(""); + setRole("Союзник"); + setAge("adult"); + setGender("female"); + setIsNsfw(false); + setAvatarUrl(""); + setCustomPrompt(""); + setEditingNpc(null); + setShowForm(false); + }; + + const openEditForm = (npc: NPCCharacter) => { + setName(npc.name); + setDescription(npc.description); + setRole(npc.role); + setAge(npc.age); + setGender(npc.gender || "female"); + setIsNsfw(npc.isNsfw || false); + setAvatarUrl(npc.avatarUrl || ""); + setEditingNpc(npc); + setShowForm(true); + }; + + const handleGenerateAvatar = async () => { + // Если нет ни кастомного промпта, ни описания - ошибка + if (!customPrompt.trim() && !description.trim()) { + alert("Введите промпт или описание персонажа"); + return; + } + + setGeneratingAvatar(true); + try { + const url = await generateAvatarUrl({ + description, + name, + age, + gender, + customPrompt: customPrompt.trim() || undefined, + isNsfw, + }); + setAvatarUrl(url); + } catch (error) { + console.error("Ошибка генерации аватара:", error); + alert("Ошибка генерации аватара: " + (error as Error).message); + } finally { + setGeneratingAvatar(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim() || !description.trim()) { + alert("Заполните имя и описание"); + return; + } + + const npcData = { + name: name.trim(), + description: description.trim(), + role, + age, + gender, + isNsfw, + avatarUrl: avatarUrl || undefined, + }; + + if (editingNpc) { + const success = await updateNPCCharacter(editingNpc.id, npcData); + if (success) { + await loadNPCs(); + resetForm(); + } else { + alert("Ошибка обновления NPC"); + } + } else { + const created = await createNPCCharacter(npcData); + if (created) { + await loadNPCs(); + resetForm(); + } else { + alert("Ошибка создания NPC"); + } + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("Удалить этого NPC?")) return; + + const success = await deleteNPCCharacter(id); + if (success) { + await loadNPCs(); + } else { + alert("Ошибка удаления"); + } + }; + + if (!user) { + return ( + + + Требуется авторизация + Войдите через Discord для доступа к NPC персонажам + + + ); + } + + return ( + + + 🎭 NPC Персонажи + Создавайте и управляйте NPC для ваших историй + setShowForm(true)}> + + Создать NPC + + + + {showForm && ( + resetForm()}> + e.stopPropagation()}> + {editingNpc ? "Редактировать NPC" : "Создать NPC"} + + + + + + Имя персонажа + setName(e.target.value)} + placeholder="Введите имя..." + required + /> + + + + Роль + setRole(e.target.value)} + > + {ROLE_OPTIONS.map((r) => ( + + {r} + + ))} + + + + + Возраст + setAge(e.target.value as CharacterAge)} + > + {AGE_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + + + + Пол + + setGender(e.target.value as CharacterGender) + } + > + {GENDER_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + + + + + setIsNsfw(e.target.checked)} + /> + NSFW (18+) + + + + + Описание + setDescription(e.target.value)} + placeholder="Опишите внешность и характер персонажа..." + rows={5} + required + /> + + + + + + + {avatarUrl ? ( + + ) : ( + 👤 + )} + + + {generatingAvatar ? "Генерация..." : "🎨 Сгенерировать"} + + setCustomPrompt(e.target.value)} + placeholder="Кастомный промпт (на английском). Если пусто - сгенерируется из описания, пола и возраста" + className="avatar-prompt-input" + rows={3} + /> + setAvatarUrl(e.target.value)} + placeholder="Или вставьте URL изображения..." + className="avatar-url-input" + /> + + + + + + + Отмена + + + {editingNpc ? "Сохранить" : "Создать"} + + + + + + )} + + {loading ? ( + Загрузка... + ) : npcs.length === 0 ? ( + + У вас пока нет NPC персонажей + Создайте первого персонажа для ваших историй! + + ) : ( + + {npcs.map((npc) => ( + + + {npc.avatarUrl ? ( + + ) : ( + 👤 + )} + + + {npc.name} + {npc.role} + + {AGE_OPTIONS.find((a) => a.value === npc.age)?.label || + npc.age} + + {npc.isNsfw && 18+} + {npc.description} + + + openEditForm(npc)} + title="Редактировать" + > + ✏️ + + handleDelete(npc.id)} + title="Удалить" + > + 🗑️ + + + + ))} + + )} + + ); +} diff --git a/src/services/api.ts b/src/services/api.ts index 0bf03cc..2219b56 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -389,6 +389,112 @@ export async function deletePlayerCharacter(id: string): Promise { } } +// ============ NPC CHARACTERS ============ + +import type { NPCCharacter } from "../types"; + +export async function getNPCCharacters(): Promise { + try { + const response = await fetch(`${API_URL}/api/npc`, { + credentials: "include", + }); + + if (response.status === 401) { + return []; + } + + if (!response.ok) { + throw new Error("Failed to fetch NPCs"); + } + + const data = await response.json(); + return data.map((c: any) => ({ ...c, id: c._id || c.id })); + } catch (error) { + console.error("Failed to get NPCs:", error); + return []; + } +} + +export async function getNPCCharacter( + id: string, +): Promise { + try { + const response = await fetch(`${API_URL}/api/npc/${id}`, { + credentials: "include", + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + return { ...data, id: data._id || data.id }; + } catch (error) { + console.error("Failed to get NPC:", error); + return null; + } +} + +export async function createNPCCharacter( + npc: Omit, +): Promise { + try { + const response = await fetch(`${API_URL}/api/npc`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(npc), + }); + + if (!response.ok) { + throw new Error("Failed to create NPC"); + } + + const data = await response.json(); + return { ...data, id: data._id }; + } catch (error) { + console.error("Failed to create NPC:", error); + return null; + } +} + +export async function updateNPCCharacter( + id: string, + npc: Partial, +): Promise { + try { + const response = await fetch(`${API_URL}/api/npc/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(npc), + }); + + return response.ok; + } catch (error) { + console.error("Failed to update NPC:", error); + return false; + } +} + +export async function deleteNPCCharacter(id: string): Promise { + try { + const response = await fetch(`${API_URL}/api/npc/${id}`, { + method: "DELETE", + credentials: "include", + }); + + return response.ok; + } catch (error) { + console.error("Failed to delete NPC:", error); + return false; + } +} + // ============ ADMIN STATS ============ interface StoryStats { diff --git a/src/services/imageGen.ts b/src/services/imageGen.ts new file mode 100644 index 0000000..6892a05 --- /dev/null +++ b/src/services/imageGen.ts @@ -0,0 +1,205 @@ +// Image generation service using GeminiGen.ai (via backend proxy) + +import type { CharacterAge, CharacterGender } from "../types"; + +const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001"; +const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions"; + +const getDeepSeekKey = () => import.meta.env.VITE_DEEPSEEK_API_KEY || ""; + +interface GenerateAvatarOptions { + description: string; + name?: string; + age?: CharacterAge; + gender?: CharacterGender; + customPrompt?: string; // Если задан - используется напрямую + isNsfw?: boolean; +} + +const AGE_PROMPTS: Record = { + child: "young child, cute, innocent", + teenager: "teenager, young, youthful", + adult: "adult, mature", + elderly: "elderly, old, wise, wrinkled", +}; + +const GENDER_PROMPTS: Record = { + male: "male, man, boy", + female: "female, woman, girl", +}; + +/** + * Translates Russian text to English using DeepSeek API + */ +async function translateToEnglish(text: string): Promise { + const apiKey = getDeepSeekKey(); + if (!apiKey) { + console.warn("No DeepSeek API key for translation"); + return text; + } + + try { + const response = await fetch(DEEPSEEK_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: "deepseek-chat", + messages: [ + { + role: "system", + content: + "Translate to English for image generation. Output ONLY the translation, nothing else. Keep character names as-is. Be concise.", + }, + { + role: "user", + content: text, + }, + ], + temperature: 0.1, + max_tokens: 150, + }), + }); + + if (!response.ok) { + console.error("Translation failed:", response.status); + return text; + } + + const data = await response.json(); + const translated = data.choices?.[0]?.message?.content?.trim(); + console.log("Translated prompt:", translated); + return translated || text; + } catch (error) { + console.error("Translation error:", error); + return text; + } +} + +/** + * Generates an image from a prompt (portrait format) + * Returns image URL + */ +async function generateImageFromPrompt(prompt: string): Promise { + console.log("Generating image with prompt:", prompt); + + const response = await fetch(`${API_BASE}/api/generate-image`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ prompt }), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Grok API error:", error); + throw new Error(`Failed to generate image: ${response.status}`); + } + + const data = await response.json(); + console.log("GeminiGen response:", data); + + if (data.url) { + return data.url; + } + + if (data.pending && data.uuid) { + return await pollForResult(data.uuid); + } + + throw new Error("Unexpected response from image generation API"); +} + +/** + * Generates an avatar image using Grok via GeminiGen.ai + * If customPrompt is provided, uses it directly + * Otherwise builds prompt from description, age, gender + * Returns image URL + */ +export async function generateAvatarUrl( + options: GenerateAvatarOptions, +): Promise { + const { description, name, age = "adult", gender = "female", customPrompt, isNsfw } = options; + + // If custom prompt provided - use it directly + if (customPrompt && customPrompt.trim()) { + return generateImageFromPrompt(customPrompt.trim()); + } + + // Build automatic prompt + const firstSentence = description + .split(/[.!?。]/)[0] + .replace(/\{user\}/gi, name || "character") + .trim() + .slice(0, 200); + + const textToTranslate = name ? `${name} - ${firstSentence}` : firstSentence; + const englishDesc = await translateToEnglish(textToTranslate); + + const ageDesc = AGE_PROMPTS[age] || AGE_PROMPTS.adult; + const genderDesc = GENDER_PROMPTS[gender] || GENDER_PROMPTS.female; + const nsfwTag = isNsfw ? "nsfw, explicit, " : ""; + + const prompt = `${nsfwTag}anime illustration, ${englishDesc}, ${genderDesc}, ${ageDesc}, full body from head to toe, entire body visible, standing pose, feet visible on ground, wide shot, masterpiece, best quality, highly detailed, vibrant colors`; + + return generateImageFromPrompt(prompt); +} + +/** + * Poll for image generation result + */ +async function pollForResult(uuid: string): Promise { + const maxAttempts = 60; // 2 minutes max + const pollInterval = 2000; // 2 seconds + + for (let i = 0; i < maxAttempts; i++) { + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + + const response = await fetch( + `${API_BASE}/api/generate-image/status/${uuid}`, + { + credentials: "include", + }, + ); + + if (!response.ok) { + throw new Error("Failed to check generation status"); + } + + const data = await response.json(); + console.log("Generation status:", data); + + if (data.done && data.url) { + return data.url; + } + + if (data.error) { + throw new Error(data.error); + } + + // Still pending, continue polling + } + + throw new Error("Image generation timed out"); +} + +/** + * Pre-fetches an image to check if it loads correctly + */ +export async function validateImageUrl(url: string): Promise { + if (url.startsWith("data:")) { + return true; // Base64 is always valid + } + + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(true); + img.onerror = () => resolve(false); + setTimeout(() => resolve(false), 60000); + img.src = url; + }); +} diff --git a/src/types/index.ts b/src/types/index.ts index 82e9f98..1ffb88d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,9 +1,30 @@ // Типы для историй и чата +export type CharacterAge = "child" | "teenager" | "adult" | "elderly"; +export type CharacterGender = "male" | "female"; + export interface Character { name: string; description: string; role: string; // например: "союзник", "злодей", "нейтральный NPC" + age?: CharacterAge; // возраст персонажа + gender?: CharacterGender; // пол персонажа + avatarUrl?: string; // URL аватара персонажа +} + +// NPC персонаж (сохранённый в БД) +export interface NPCCharacter { + id: string; + userId: string; + name: string; + description: string; + role: string; + age: CharacterAge; + gender: CharacterGender; + isNsfw?: boolean; + avatarUrl?: string; + createdAt: Date; + updatedAt: Date; } // Персонаж пользователя (для игры) diff --git a/vite.config.ts b/vite.config.ts index df04b69..be0f4b3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,8 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import { readFileSync } from 'fs' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { readFileSync } from "fs"; -const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')) +const pkg = JSON.parse(readFileSync("./package.json", "utf-8")); // https://vite.dev/config/ export default defineConfig({ @@ -10,4 +10,4 @@ export default defineConfig({ define: { __APP_VERSION__: JSON.stringify(pkg.version), }, -}) +});
Войдите через Discord для доступа к NPC персонажам
Создавайте и управляйте NPC для ваших историй
У вас пока нет NPC персонажей
Создайте первого персонажа для ваших историй!
{npc.description}