feat: banner generation, improved memory system, streaming text animation

- Add banner/cover generation for stories with character reference support
- Improve summary system: generate every 8 msgs or when context large
- Enhance summary prompt to preserve critical story info (promises, relationships)
- Add typewriter text animation during AI streaming
- Increase context to 20 messages, lower summary temperature to 0.1
- Server: auto-truncate long messages instead of rejecting
This commit is contained in:
Alexej Wolff
2026-05-09 06:39:10 +02:00
parent f6fffc1561
commit e8cd01c693
9 changed files with 1498 additions and 260 deletions
+96 -3
View File
@@ -9,7 +9,7 @@ import {
getNPCCharacters,
createNPCCharacter,
} from "../services/api";
import { generateAvatarUrl } from "../services/imageGen";
import { generateAvatarUrl, generateBannerUrl } from "../services/imageGen";
import { useStoryGeneration } from "../hooks/useStoryGeneration";
import type {
Character,
@@ -92,6 +92,8 @@ export default function CreateStoryPage() {
const [generatingAvatarIndex, setGeneratingAvatarIndex] = useState<
number | null
>(null);
const [generatingBanner, setGeneratingBanner] = useState(false);
const [coverImage, setCoverImage] = useState("");
const [savedNPCs, setSavedNPCs] = useState<NPCCharacter[]>([]);
const [showNPCSelector, setShowNPCSelector] = useState(false);
@@ -154,6 +156,7 @@ export default function CreateStoryPage() {
const story = await getStory(id);
if (story) {
setCoverImage(story.coverImage || "");
setForm({
title: story.title,
description: story.description || "",
@@ -382,6 +385,37 @@ export default function CreateStoryPage() {
}
};
// Генерация баннера/обложки
const handleGenerateBanner = async () => {
if (!form.title && !form.description && !form.summary) {
alert("Заполните название или описание для генерации баннера");
return;
}
setGeneratingBanner(true);
try {
// Передаём персонажей для поиска референса
const charactersWithAvatars = form.characters
.filter((c) => c.name && c.avatarUrl)
.map((c) => ({ name: c.name, avatarUrl: c.avatarUrl }));
const url = await generateBannerUrl({
title: form.title,
description: form.description || form.summary,
genre: form.genres,
setting: form.settings,
isNsfw: form.isNsfw,
characters: charactersWithAvatars,
});
setCoverImage(url);
} catch (error) {
console.error("Failed to generate banner:", error);
alert("Ошибка при генерации баннера");
} finally {
setGeneratingBanner(false);
}
};
// Правила мира
const handleRuleChange = (index: number, value: string) => {
const newRules = [...form.worldRules];
@@ -430,7 +464,7 @@ export default function CreateStoryPage() {
const storyData = {
title: form.title,
description: form.description || `Исекай история: ${form.title}`,
coverImage: "",
coverImage: coverImage,
language:
LANGUAGES.find((l) => l.code === form.language)?.name.split(" ")[1] ||
"Русский",
@@ -622,9 +656,10 @@ export default function CreateStoryPage() {
</p>
<div className="temperature-selector">
{[
{ value: 0.75, label: "🎯 Точный", desc: "0.75" },
{ value: 0.9, label: "⚖️ Сбалансированный", desc: "0.9" },
{ value: 0.95, label: "✨ Живой", desc: "0.95" },
{ value: 1.05, label: "🎨 Креативный", desc: "1.05" },
{ value: 1.0, label: "🎨 Креативный", desc: "1.0" },
].map((opt) => (
<button
key={opt.value}
@@ -803,6 +838,64 @@ export default function CreateStoryPage() {
</div>
</section>
{/* Баннер/Обложка */}
<section className="form-section">
<h2>🖼 Обложка</h2>
<div className="form-group">
<div className="banner-generator">
<div className="banner-preview">
{coverImage ? (
<img
src={coverImage}
alt="Обложка истории"
className="banner-image"
/>
) : (
<div className="banner-placeholder">
<span>📷 Нет обложки</span>
</div>
)}
</div>
<div className="banner-controls">
<button
type="button"
onClick={handleGenerateBanner}
className="btn-generate-banner"
disabled={generatingBanner}
>
{generatingBanner
? "⏳ Генерация..."
: "✨ Сгенерировать обложку"}
</button>
<p className="field-hint">
ИИ создаст обложку на основе названия, описания и жанров
истории.
</p>
<div className="banner-url-input">
<input
type="url"
value={coverImage}
onChange={(e) => setCoverImage(e.target.value)}
placeholder="Или вставьте URL изображения..."
className="url-input"
/>
{coverImage && (
<button
type="button"
onClick={() => setCoverImage("")}
className="clear-url-btn"
title="Удалить обложку"
>
</button>
)}
</div>
</div>
</div>
</div>
</section>
{/* Первое сообщение */}
<section className="form-section">
<h2>