feat: NPC system improvements - custom prompt, NSFW, full body generation
This commit is contained in:
+250
-38
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user