feat: NPC system improvements - custom prompt, NSFW, full body generation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -26,6 +26,9 @@ export function Header() {
|
||||
<Link to="/characters" className="nav-link">
|
||||
Персонажи
|
||||
</Link>
|
||||
<Link to="/npc" className="nav-link">
|
||||
NPC
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// Персонаж пользователя (для игры)
|
||||
|
||||
Reference in New Issue
Block a user