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
+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>
);
}