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