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="Имя" - /> - -