feat: NPC system improvements - custom prompt, NSFW, full body generation

This commit is contained in:
Alexej Wolff
2026-05-05 00:11:43 +02:00
parent efd2332875
commit dad5aa47cb
11 changed files with 1860 additions and 43 deletions
+229
View File
@@ -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, () => {
+2
View File
@@ -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() {
<Route path="/create" element={<CreateStoryPage />} />
<Route path="/edit/:id" element={<CreateStoryPage />} />
<Route path="/characters" element={<CharactersPage />} />
<Route path="/npc" element={<NPCPage />} />
<Route path="/play/:id" element={<GamePage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
+3
View File
@@ -26,6 +26,9 @@ export function Header() {
<Link to="/characters" className="nav-link">
Персонажи
</Link>
<Link to="/npc" className="nav-link">
NPC
</Link>
</>
)}
</nav>
+201
View File
@@ -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;
}
+219 -7
View File
@@ -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<NPCCharacter[]>([]);
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,6 +889,29 @@ export default function CreateStoryPage() {
)}
</div>
<div className="character-content">
{/* Avatar section */}
<div className="character-avatar-section">
{char.avatarUrl ? (
<img
src={char.avatarUrl}
alt={char.name || "Персонаж"}
className="character-avatar-preview"
/>
) : (
<div className="character-avatar-placeholder">👤</div>
)}
<button
type="button"
onClick={() => handleGenerateAvatar(index)}
className="btn-generate-avatar"
disabled={generatingAvatarIndex === index}
>
{generatingAvatarIndex === index ? "⏳" : "🎨"}
</button>
</div>
{/* Fields section */}
<div className="character-fields">
<input
type="text"
@@ -792,6 +921,7 @@ export default function CreateStoryPage() {
}
placeholder="Имя"
/>
<div className="character-row">
<select
value={char.role}
onChange={(e) =>
@@ -804,21 +934,103 @@ export default function CreateStoryPage() {
</option>
))}
</select>
<select
value={char.age || "adult"}
onChange={(e) =>
handleCharacterChange(index, "age", e.target.value)
}
>
{CHARACTER_AGES.map((age) => (
<option key={age.value} value={age.value}>
{age.label}
</option>
))}
</select>
<select
value={char.gender || "female"}
onChange={(e) =>
handleCharacterChange(index, "gender", e.target.value)
}
>
{CHARACTER_GENDERS.map((g) => (
<option key={g.value} value={g.value}>
{g.label}
</option>
))}
</select>
</div>
<textarea
value={char.description}
onChange={(e) =>
handleCharacterChange(index, "description", e.target.value)
handleCharacterChange(
index,
"description",
e.target.value,
)
}
placeholder="Описание персонажа..."
rows={2}
/>
</div>
</div>
</div>
))}
<div className="character-buttons">
<button type="button" onClick={addCharacter} className="add-btn">
+ Добавить персонажа
</button>
{savedNPCs.length > 0 && (
<button
type="button"
onClick={() => setShowNPCSelector(true)}
className="add-btn secondary"
>
📚 Выбрать из библиотеки ({savedNPCs.length})
</button>
)}
</div>
{/* NPC Selector Modal */}
{showNPCSelector && (
<div
className="npc-selector-overlay"
onClick={() => setShowNPCSelector(false)}
>
<div
className="npc-selector-modal"
onClick={(e) => e.stopPropagation()}
>
<h3>Выберите NPC из библиотеки</h3>
<div className="npc-selector-grid">
{savedNPCs.map((npc) => (
<div
key={npc.id}
className="npc-selector-item"
onClick={() => selectSavedNPC(npc)}
>
{npc.avatarUrl ? (
<img src={npc.avatarUrl} alt={npc.name} />
) : (
<div className="npc-placeholder">👤</div>
)}
<div className="npc-selector-info">
<strong>{npc.name}</strong>
<span>{npc.role}</span>
</div>
</div>
))}
</div>
<button
type="button"
className="cancel-btn"
onClick={() => setShowNPCSelector(false)}
>
Закрыть
</button>
</div>
</div>
)}
</section>
{/* Submit */}
+466
View File
@@ -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;
}
}
+372
View File
@@ -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<NPCCharacter[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingNpc, setEditingNpc] = useState<NPCCharacter | null>(null);
const [generatingAvatar, setGeneratingAvatar] = useState(false);
// Форма
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [role, setRole] = useState("Союзник");
const [age, setAge] = useState<CharacterAge>("adult");
const [gender, setGender] = useState<CharacterGender>("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 (
<div className="npc-page">
<div className="auth-required">
<h2>Требуется авторизация</h2>
<p>Войдите через Discord для доступа к NPC персонажам</p>
</div>
</div>
);
}
return (
<div className="npc-page">
<div className="npc-header">
<h1>🎭 NPC Персонажи</h1>
<p>Создавайте и управляйте NPC для ваших историй</p>
<button className="btn-create-npc" onClick={() => setShowForm(true)}>
+ Создать NPC
</button>
</div>
{showForm && (
<div className="npc-form-overlay" onClick={() => resetForm()}>
<div className="npc-form-modal" onClick={(e) => e.stopPropagation()}>
<h2>{editingNpc ? "Редактировать NPC" : "Создать NPC"}</h2>
<form onSubmit={handleSubmit}>
<div className="form-row">
<div className="form-left">
<div className="form-group">
<label>Имя персонажа</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Введите имя..."
required
/>
</div>
<div className="form-group">
<label>Роль</label>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Возраст</label>
<select
value={age}
onChange={(e) => setAge(e.target.value as CharacterAge)}
>
{AGE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="form-group">
<label>Пол</label>
<select
value={gender}
onChange={(e) =>
setGender(e.target.value as CharacterGender)
}
>
{GENDER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="form-group form-checkbox">
<label>
<input
type="checkbox"
checked={isNsfw}
onChange={(e) => setIsNsfw(e.target.checked)}
/>
NSFW (18+)
</label>
</div>
<div className="form-group">
<label>Описание</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Опишите внешность и характер персонажа..."
rows={5}
required
/>
</div>
</div>
<div className="form-right">
<div className="avatar-section">
<div className="avatar-preview-large">
{avatarUrl ? (
<img src={avatarUrl} alt={name} />
) : (
<div className="avatar-placeholder-large">👤</div>
)}
</div>
<button
type="button"
className="btn-generate-avatar"
onClick={handleGenerateAvatar}
disabled={generatingAvatar || (!customPrompt.trim() && !description.trim())}
>
{generatingAvatar ? "Генерация..." : "🎨 Сгенерировать"}
</button>
<textarea
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Кастомный промпт (на английском). Если пусто - сгенерируется из описания, пола и возраста"
className="avatar-prompt-input"
rows={3}
/>
<input
type="text"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="Или вставьте URL изображения..."
className="avatar-url-input"
/>
</div>
</div>
</div>
<div className="form-actions">
<button
type="button"
className="btn-cancel"
onClick={resetForm}
>
Отмена
</button>
<button type="submit" className="btn-save">
{editingNpc ? "Сохранить" : "Создать"}
</button>
</div>
</form>
</div>
</div>
)}
{loading ? (
<div className="loading">Загрузка...</div>
) : npcs.length === 0 ? (
<div className="empty-state">
<p>У вас пока нет NPC персонажей</p>
<p>Создайте первого персонажа для ваших историй!</p>
</div>
) : (
<div className="npc-grid">
{npcs.map((npc) => (
<div key={npc.id} className="npc-card">
<div className="npc-card-avatar">
{npc.avatarUrl ? (
<img src={npc.avatarUrl} alt={npc.name} />
) : (
<div className="npc-avatar-placeholder">👤</div>
)}
</div>
<div className="npc-card-info">
<h3>{npc.name}</h3>
<span className="npc-role">{npc.role}</span>
<span className="npc-age">
{AGE_OPTIONS.find((a) => a.value === npc.age)?.label ||
npc.age}
</span>
{npc.isNsfw && <span className="npc-nsfw">18+</span>}
<p className="npc-description">{npc.description}</p>
</div>
<div className="npc-card-actions">
<button
className="btn-edit"
onClick={() => openEditForm(npc)}
title="Редактировать"
>
</button>
<button
className="btn-delete"
onClick={() => handleDelete(npc.id)}
title="Удалить"
>
🗑
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+106
View File
@@ -389,6 +389,112 @@ export async function deletePlayerCharacter(id: string): Promise<boolean> {
}
}
// ============ NPC CHARACTERS ============
import type { NPCCharacter } from "../types";
export async function getNPCCharacters(): Promise<NPCCharacter[]> {
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<NPCCharacter | null> {
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<NPCCharacter, "id" | "userId" | "createdAt" | "updatedAt">,
): Promise<NPCCharacter | null> {
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<NPCCharacter>,
): Promise<boolean> {
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<boolean> {
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 {
+205
View File
@@ -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<CharacterAge, string> = {
child: "young child, cute, innocent",
teenager: "teenager, young, youthful",
adult: "adult, mature",
elderly: "elderly, old, wise, wrinkled",
};
const GENDER_PROMPTS: Record<CharacterGender, string> = {
male: "male, man, boy",
female: "female, woman, girl",
};
/**
* Translates Russian text to English using DeepSeek API
*/
async function translateToEnglish(text: string): Promise<string> {
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<string> {
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<string> {
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<string> {
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<boolean> {
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;
});
}
+21
View File
@@ -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;
}
// Персонаж пользователя (для игры)
+5 -5
View File
@@ -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),
},
})
});