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
+250 -38
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,42 +889,148 @@ export default function CreateStoryPage() {
)}
</div>
<div className="character-fields">
<input
type="text"
value={char.name}
onChange={(e) =>
handleCharacterChange(index, "name", e.target.value)
}
placeholder="Имя"
/>
<select
value={char.role}
onChange={(e) =>
handleCharacterChange(index, "role", e.target.value)
}
>
{CHARACTER_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<textarea
value={char.description}
onChange={(e) =>
handleCharacterChange(index, "description", e.target.value)
}
placeholder="Описание персонажа..."
rows={2}
/>
<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"
value={char.name}
onChange={(e) =>
handleCharacterChange(index, "name", e.target.value)
}
placeholder="Имя"
/>
<div className="character-row">
<select
value={char.role}
onChange={(e) =>
handleCharacterChange(index, "role", e.target.value)
}
>
{CHARACTER_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</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,
)
}
placeholder="Описание персонажа..."
rows={2}
/>
</div>
</div>
</div>
))}
<button type="button" onClick={addCharacter} className="add-btn">
+ Добавить персонажа
</button>
<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 */}