Compare commits

..

7 Commits

Author SHA1 Message Date
Alexej Wolff e8cd01c693 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
2026-05-09 06:39:10 +02:00
Alexej Wolff f6fffc1561 feat: increase max_tokens 2x (1500->3000) 2026-05-07 01:19:41 +02:00
Alexej Wolff 9bf40a0aa6 fix: explicit UTF-8 charset everywhere 2026-05-07 01:02:55 +02:00
Alexej Wolff 51964edff7 fix: add buffer for SSE stream parsing to prevent truncated words 2026-05-07 01:01:07 +02:00
Alexej Wolff ee0b52f88a fix: add top_p 0.95, stricter word completion rules 2026-05-07 00:57:24 +02:00
Alexej Wolff a698b5f832 fix: increase max_tokens to 1500, stricter grammar rules 2026-05-07 00:55:00 +02:00
Alexej Wolff 249d21364e feat: add 'save as global NPC' toggle for story characters 2026-05-07 00:44:17 +02:00
10 changed files with 1593 additions and 288 deletions
+56 -27
View File
@@ -198,18 +198,16 @@ function validateDeepSeekRequest(body) {
const errors = [];
const { messages, temperature, max_tokens } = body;
// Validate messages
// Validate messages - only check critical errors, truncation handled in sanitize
if (!Array.isArray(messages)) {
errors.push("messages must be an array");
} else {
if (messages.length === 0) {
errors.push("messages cannot be empty");
}
if (messages.length > DEEPSEEK_LIMITS.MAX_MESSAGES) {
errors.push(`too many messages (max ${DEEPSEEK_LIMITS.MAX_MESSAGES})`);
}
// Don't error on too many messages - we'll truncate them
// Don't error on too long messages - we'll truncate them
let totalLength = 0;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!msg || typeof msg !== "object") {
@@ -223,21 +221,8 @@ function validateDeepSeekRequest(body) {
}
if (typeof msg.content !== "string") {
errors.push(`messages[${i}].content must be a string`);
} else {
if (msg.content.length > DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH) {
errors.push(
`messages[${i}].content too long (max ${DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH} chars)`,
);
}
totalLength += msg.content.length;
}
}
if (totalLength > DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH) {
errors.push(
`total message content too long (max ${DEEPSEEK_LIMITS.MAX_TOTAL_LENGTH} chars)`,
);
}
}
// Validate temperature
@@ -264,8 +249,37 @@ function validateDeepSeekRequest(body) {
return errors;
}
/**
* Truncate ONLY individual messages that are too long.
* Does NOT remove messages - that would lose context.
* If total is too long, client should handle via summary generation.
*/
function truncateMessagesToFit(messages) {
const MAX_PER_MSG = DEEPSEEK_LIMITS.MAX_MESSAGE_LENGTH;
// Only truncate individual messages that are too long - keep the END (recent context)
return messages.map((msg, idx) => {
if (msg.content && msg.content.length > MAX_PER_MSG) {
console.log(
`Truncating message ${idx} (${msg.role}) from ${msg.content.length} to ${MAX_PER_MSG} chars - keeping END`,
);
// Keep end of message (most recent context is more important)
return {
...msg,
content:
"[earlier content truncated]...\n\n" +
msg.content.slice(-MAX_PER_MSG + 50),
};
}
return msg;
});
}
function sanitizeDeepSeekMessages(messages) {
return messages.map((msg) => ({
// First truncate to fit limits
const truncatedMessages = truncateMessagesToFit(messages);
return truncatedMessages.map((msg) => ({
role: msg.role,
content: String(msg.content),
}));
@@ -1059,7 +1073,7 @@ app.get("/api/admin/stats", requireAuth, async (req, res) => {
// Прокси для генерации изображений через Grok (обход CORS)
app.post("/api/generate-image", requireAuth, async (req, res) => {
try {
const { prompt } = req.body;
const { prompt, orientation = "portrait", refHistory } = req.body;
const apiKey = process.env.GEMINIGEN_API_KEY;
if (!apiKey) {
@@ -1068,14 +1082,27 @@ app.post("/api/generate-image", requireAuth, async (req, res) => {
.json({ error: "GeminiGen API key not configured" });
}
console.log("Generating image with Grok, prompt:", prompt);
console.log(
"Generating image with Grok, prompt:",
prompt,
"orientation:",
orientation,
"refHistory:",
refHistory,
);
// Используем FormData для multipart/form-data
const formData = new FormData();
formData.append("prompt", prompt);
formData.append("orientation", "portrait"); // 9:16
formData.append("orientation", orientation); // portrait (9:16) or landscape (16:9)
formData.append("num_result", "1");
// Добавляем референс истории если есть (для консистентности персонажей)
if (refHistory) {
formData.append("ref_history", refHistory);
console.log("Using ref_history:", refHistory);
}
const response = await fetch(
"https://api.geminigen.ai/uapi/v1/imagen/grok",
{
@@ -1217,7 +1244,7 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/json; charset=utf-8",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
@@ -1225,6 +1252,7 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
messages: sanitizedMessages,
temperature,
max_tokens: clampedMaxTokens,
top_p: 0.95,
}),
});
@@ -1278,7 +1306,7 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/json; charset=utf-8",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
@@ -1286,6 +1314,7 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
messages: sanitizedMessages,
temperature,
max_tokens: clampedMaxTokens,
top_p: 0.95,
stream: true,
}),
});
@@ -1297,13 +1326,13 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
}
// Set headers for SSE
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Pipe the stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
const decoder = new TextDecoder("utf-8");
const pump = async () => {
try {
@@ -1366,7 +1395,7 @@ app.post("/api/deepseek/translate", requireAuth, async (req, res) => {
const response = await fetch(DEEPSEEK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/json; charset=utf-8",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
+91
View File
@@ -0,0 +1,91 @@
import { useRef, useEffect, useState, useCallback } from "react";
import ReactMarkdown from "react-markdown";
interface StreamingTextProps {
content: string;
isStreaming?: boolean;
}
/**
* Component that renders text with typewriter-like fade-in animation during streaming.
* Characters appear gradually with a soft fade effect.
*/
export default function StreamingText({
content,
isStreaming = false,
}: StreamingTextProps) {
const [visibleChars, setVisibleChars] = useState(content.length);
const targetCharsRef = useRef(content.length);
const animationFrameRef = useRef<number | null>(null);
const animate = useCallback(() => {
setVisibleChars((prev) => {
const target = targetCharsRef.current;
if (prev >= target) {
animationFrameRef.current = null;
return prev;
}
// Reveal 3-5 characters per frame for smooth but fast animation
const step = Math.min(5, Math.max(3, Math.ceil((target - prev) / 10)));
const next = Math.min(prev + step, target);
// Continue animation
animationFrameRef.current = requestAnimationFrame(animate);
return next;
});
}, []);
useEffect(() => {
if (!isStreaming) {
// Not streaming - show all immediately
setVisibleChars(content.length);
targetCharsRef.current = content.length;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
return;
}
// Content changed
targetCharsRef.current = content.length;
// Start animation if not running
if (!animationFrameRef.current && visibleChars < content.length) {
animationFrameRef.current = requestAnimationFrame(animate);
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
}, [content, isStreaming, visibleChars, animate]);
// Reset when content is cleared (new message)
useEffect(() => {
if (content.length === 0 || content.length < visibleChars - 100) {
setVisibleChars(0);
}
}, [content.length, visibleChars]);
// Not streaming - render markdown normally
if (!isStreaming) {
return (
<div className="streaming-text">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
);
}
// Streaming - show only visible characters with typewriter effect
const visibleText = content.slice(0, visibleChars);
return (
<div className="streaming-text is-streaming">
<span className="visible-text">{visibleText}</span>
<span className="streaming-cursor"></span>
</div>
);
}
+1
View File
@@ -8,3 +8,4 @@ export {
export { ChatInput } from "./ChatInput";
export { SessionSelector } from "./SessionSelector";
export { CharacterPanel } from "./CharacterPanel";
export { default as StreamingText } from "./StreamingText";
+138
View File
@@ -714,6 +714,27 @@
border-color: #667eea;
}
.save-global-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.85rem;
color: #888;
cursor: pointer;
}
.save-global-toggle input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #667eea;
cursor: pointer;
}
.save-global-toggle:hover {
color: #aaa;
}
/* Подсказки */
.hint {
display: block;
@@ -1113,3 +1134,120 @@
justify-content: center;
}
}
/* Banner/Cover Image */
.banner-generator {
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.banner-preview {
flex-shrink: 0;
width: 280px;
aspect-ratio: 16 / 9;
border-radius: 12px;
overflow: hidden;
background: #252525;
border: 2px dashed #444;
}
.banner-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 1rem;
}
.banner-controls {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.btn-generate-banner {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border: none;
border-radius: 8px;
color: white;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
width: fit-content;
}
.btn-generate-banner:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.btn-generate-banner:disabled {
opacity: 0.6;
cursor: wait;
}
.banner-url-input {
display: flex;
gap: 0.5rem;
align-items: center;
}
.banner-url-input .url-input {
flex: 1;
padding: 0.6rem 0.8rem;
background: #252525;
border: 1px solid #444;
border-radius: 8px;
color: #e5e5e5;
font-size: 0.85rem;
}
.banner-url-input .url-input:focus {
outline: none;
border-color: #667eea;
}
.clear-url-btn {
padding: 0.5rem 0.75rem;
background: #3a3a3a;
border: 1px solid #555;
border-radius: 6px;
color: #999;
cursor: pointer;
transition: all 0.2s;
}
.clear-url-btn:hover {
background: #ef4444;
border-color: #ef4444;
color: white;
}
@media (max-width: 600px) {
.banner-generator {
flex-direction: column;
}
.banner-preview {
width: 100%;
}
.btn-generate-banner {
width: 100%;
justify-content: center;
}
}
+115 -5
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 || "",
@@ -274,7 +277,7 @@ export default function CreateStoryPage() {
const handleCharacterChange = (
index: number,
field: keyof Character,
value: string,
value: string | boolean,
) => {
const newCharacters = [...form.characters];
newCharacters[index] = { ...newCharacters[index], [field]: value };
@@ -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] ||
"Русский",
@@ -454,7 +488,10 @@ export default function CreateStoryPage() {
const saveCharactersAsGlobalNPCs = async () => {
const existingNames = savedNPCs.map((npc) => npc.name.toLowerCase());
for (const char of storyCharacters) {
// Only save characters that have saveAsGlobal flag enabled
const charsToSave = storyCharacters.filter((c) => c.saveAsGlobal);
for (const char of charsToSave) {
// Skip if NPC with this name already exists
if (existingNames.includes(char.name.toLowerCase())) {
continue;
@@ -619,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}
@@ -800,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>
@@ -1046,6 +1142,20 @@ export default function CreateStoryPage() {
placeholder="Описание персонажа..."
rows={2}
/>
<label className="save-global-toggle">
<input
type="checkbox"
checked={char.saveAsGlobal || false}
onChange={(e) =>
handleCharacterChange(
index,
"saveAsGlobal",
e.target.checked,
)
}
/>
<span>Сделать общим (добавить в библиотеку NPC)</span>
</label>
</div>
</div>
</div>
+305 -29
View File
@@ -14,6 +14,39 @@
touch-action: none;
}
/* Streaming text animation - typewriter effect */
@keyframes cursorBlink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
.streaming-text {
line-height: 1.6;
}
.streaming-text.is-streaming {
white-space: pre-wrap;
word-wrap: break-word;
}
.streaming-text .visible-text {
display: inline;
}
.streaming-text .streaming-cursor {
display: inline;
animation: cursorBlink 0.8s infinite;
color: #667eea;
margin-left: 1px;
font-weight: normal;
}
.game-page * {
touch-action: pan-y;
}
@@ -243,6 +276,7 @@
overflow: hidden;
position: relative;
width: 100%;
align-items: center;
}
.scroll-to-bottom-btn {
@@ -278,12 +312,13 @@
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
padding: 1rem;
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
padding: 1.5rem 2rem;
padding-left: max(2rem, env(safe-area-inset-left));
padding-right: max(2rem, env(safe-area-inset-right));
padding-bottom: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
box-sizing: border-box;
@@ -312,10 +347,11 @@
}
.message {
max-width: 88%;
max-width: 750px;
animation: fadeIn 0.25s ease;
word-wrap: break-word;
overflow-wrap: break-word;
width: 100%;
}
@keyframes fadeIn {
@@ -330,31 +366,49 @@
}
.message.user {
align-self: flex-end;
/* No align-self - inherit center from parent */
}
.message.assistant {
align-self: flex-start;
/* No align-self - inherit center from parent */
}
.message-content {
padding: 0.875rem 1rem;
border-radius: 18px;
line-height: 1.7;
font-size: 1.1rem;
padding: 0.5rem 0;
line-height: 1.8;
font-size: 1.05rem;
text-align: justify;
text-justify: inter-word;
}
@media (max-width: 768px) {
.message-content {
font-size: 22px;
line-height: 1.8;
font-size: 1rem;
line-height: 1.75;
text-align: left;
}
.messages-container {
padding: 1rem;
gap: 0.5rem;
}
.messages-container > * {
max-width: 100%;
}
}
.message.user {
/* Modern minimal card style */
}
.message.user .message-content {
background: #2563eb;
color: white;
border-bottom-right-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: #9ca3af;
border-left: 2px solid #3b82f6;
padding: 0.75rem 1rem 0.75rem 1rem;
text-align: left;
border-radius: 0 8px 8px 0;
}
.message.assistant .message-content {
@@ -364,12 +418,18 @@
padding: 0.5rem 0;
}
/* Loading text */
.loading-text {
color: #9ca3af;
font-style: italic;
}
.message-content p {
margin: 0;
}
.message-content p + p {
margin-top: 0.75rem;
margin-top: 0.25rem;
}
/* Markdown стили */
@@ -418,9 +478,10 @@
.message-footer {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
margin-top: 0.25rem;
padding: 0 0.4rem;
padding: 0;
gap: 1rem;
}
.message-time {
@@ -429,11 +490,13 @@
}
.message.user .message-time {
color: rgba(255, 255, 255, 0.4);
color: #6b7280;
}
.message.user .message-footer {
flex-direction: row-reverse;
flex-direction: row;
padding-left: 1.75rem;
margin-top: 0.35rem;
}
.message-actions {
@@ -639,19 +702,122 @@
border-color: #667eea;
}
/* Anime-style loading indicator */
.anime-loader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
gap: 1rem;
max-width: 750px;
width: 100%;
}
.loader-ring {
width: 48px;
height: 48px;
position: relative;
animation: loader-rotate 2s linear infinite;
}
.loader-ring::before,
.loader-ring::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid transparent;
}
.loader-ring::before {
border-top-color: #8b5cf6;
border-right-color: #8b5cf6;
animation: loader-pulse 1.5s ease-in-out infinite;
}
.loader-ring::after {
border-bottom-color: #ec4899;
border-left-color: #ec4899;
animation: loader-pulse 1.5s ease-in-out infinite reverse;
}
.loader-ring-inner {
position: absolute;
inset: 6px;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: #06b6d4;
border-bottom-color: #06b6d4;
animation: loader-rotate 1s linear infinite reverse;
}
.loader-text {
color: #6b7280;
font-size: 0.85rem;
letter-spacing: 0.05em;
animation: loader-text-fade 1.5s ease-in-out infinite;
}
.stop-generation-btn {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #f87171;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.stop-generation-btn:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
@keyframes loader-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes loader-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes loader-text-fade {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
.input-container {
flex-shrink: 0;
display: flex;
align-items: flex-end;
gap: 0.5rem;
margin: 0.5rem;
margin-left: max(0.5rem, env(safe-area-inset-left));
margin-right: max(0.5rem, env(safe-area-inset-right));
margin: 0.5rem auto;
margin-bottom: max(0.5rem, env(safe-area-inset-bottom));
padding: 0.5rem;
background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
max-width: 750px;
width: calc(100% - 1rem);
}
.input-container textarea {
@@ -774,15 +940,64 @@
color: #f59e0b;
}
/* Streaming message animation */
.message.streaming .message-text {
/* Streaming message animation - simple fade effect */
.message.streaming .message-content {
position: relative;
}
.message.streaming .message-text::after {
content: "▋";
animation: blink 1s infinite;
margin-left: 2px;
/* Simple fade-in for streaming text */
.message.streaming .message-content,
.message.continuing .message-content {
animation: simpleFadeIn 0.3s ease-out;
}
@keyframes simpleFadeIn {
from {
opacity: 0.7;
}
to {
opacity: 1;
}
}
/* Message continuing animation */
.message.continuing .message-content {
position: relative;
}
/* Small loader for Continue mode */
.continue-loader {
display: flex;
justify-content: center;
padding: 0.5rem;
color: #6b7280;
}
.continue-loader-dots span {
animation: continueDot 1.4s infinite ease-in-out both;
font-size: 1.5rem;
line-height: 1;
}
.continue-loader-dots span:nth-child(1) {
animation-delay: 0s;
}
.continue-loader-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.continue-loader-dots span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes continueDot {
0%,
80%,
100% {
opacity: 0.3;
}
40% {
opacity: 1;
}
}
/* Game Layout with Character Panel */
@@ -1012,3 +1227,64 @@
padding: 0.35rem 0.6rem;
}
}
/* Chat Action Buttons (Retry, Erase) */
.chat-actions {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
justify-content: center;
flex-shrink: 0;
max-width: 750px;
margin: 0 auto;
width: 100%;
}
.action-btn {
padding: 0.4rem 0.8rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: #888;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.3rem;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #aaa;
border-color: rgba(255, 255, 255, 0.25);
}
.action-btn.retry-btn:hover {
background: rgba(59, 130, 246, 0.15);
border-color: rgba(59, 130, 246, 0.4);
color: #60a5fa;
}
.action-btn.erase-btn:hover {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
color: #f87171;
}
.action-btn.continue-btn:hover {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: #4ade80;
}
@media (max-width: 768px) {
.chat-actions {
padding: 0.4rem 0.75rem;
}
.action-btn {
padding: 0.35rem 0.6rem;
font-size: 0.7rem;
}
}
+529 -181
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useParams, Link, useSearchParams } from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { useAuth } from "../contexts/AuthContext";
import { saveSession as apiSaveSession } from "../services/api";
import {
@@ -18,6 +19,7 @@ import type {
ChatMessage,
MessageVersion,
PlayerCharacter,
WorldState,
} from "../types";
import {
useGameSession,
@@ -25,7 +27,11 @@ import {
useCharacterDetection,
useLazyMessages,
} from "../hooks";
import { SessionSelector, CharacterPanel } from "../components/game";
import {
SessionSelector,
CharacterPanel,
StreamingText,
} from "../components/game";
import "./GamePage.css";
function generateId(): string {
@@ -76,7 +82,7 @@ export default function GamePage() {
resetStreaming,
startStreaming,
abortController,
abort,
// abort - не используется, т.к. кнопка "Остановить" закомментирована
getLatestContent,
} = useStreamingResponse();
@@ -108,6 +114,7 @@ export default function GamePage() {
// Local state
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isContinuing, setIsContinuing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
@@ -329,7 +336,7 @@ export default function GamePage() {
const response = await generateStoryResponseStream(
story,
session.messages,
input.trim(),
userMessage.content, // Use userMessage.content to include [OOC: ...] wrapper
updateStreamingContent,
playerCharacter || undefined,
session,
@@ -347,31 +354,51 @@ export default function GamePage() {
const allMessages = [...updatedMessages, assistantMessage];
const newKeyEvents = await extractKeyEvents(
response,
session.keyEvents || [],
);
// Skip state updates for OOC messages - they are meta-conversations
let newKeyEvents = session.keyEvents || [];
let newSummary = session.storySummary;
if (allMessages.length % 15 === 0 && allMessages.length > 0) {
newSummary = await generateStorySummary(
story,
allMessages,
session.storySummary,
);
}
// Update world state for better character tracking
let newWorldState = session.worldState;
if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
try {
newWorldState = await updateWorldState(
if (!isOocMode) {
newKeyEvents = await extractKeyEvents(
response,
session.keyEvents || [],
);
// Calculate total context size
const totalContextSize = allMessages.reduce(
(sum, m) => sum + m.content.length,
0,
);
const contextThreshold = 100000; // Generate summary when context gets large
// Generate summary more frequently (every 8 messages) OR when context is large
const shouldGenerateSummary =
(allMessages.length % 8 === 0 && allMessages.length > 0) ||
(totalContextSize > contextThreshold && allMessages.length > 10);
if (shouldGenerateSummary) {
console.log(
`Generating summary: ${allMessages.length} messages, ${totalContextSize} chars`,
);
newSummary = await generateStorySummary(
story,
allMessages,
session.worldState,
session.storySummary,
);
} catch (e) {
console.warn("Failed to update world state:", e);
}
// Update world state for better character tracking
if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
try {
newWorldState = await updateWorldState(
story,
allMessages,
session.worldState,
);
} catch (e) {
console.warn("Failed to update world state:", e);
}
}
}
@@ -417,8 +444,243 @@ export default function GamePage() {
}
};
const handleStop = () => {
abort();
// Закомментирована кнопка остановки - функция сохранена на будущее
// const handleStop = () => {
// abort();
// };
// Retry: regenerate last AI response
const handleRetry = async () => {
if (!story || !session || !currentSessionId || isLoading) return;
if (session.messages.length < 2) return;
// Find last assistant message and corresponding user message
const messages = session.messages;
let lastAssistantIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIndex = i;
break;
}
}
if (lastAssistantIndex < 1) return; // Need at least user + assistant
const lastUserMessage = messages[lastAssistantIndex - 1];
if (lastUserMessage.role !== "user") return;
// Remove last assistant message, keep everything before it
const messagesWithoutLast = messages.slice(0, lastAssistantIndex);
const tempSession = { ...session, messages: messagesWithoutLast };
setSession(tempSession);
pendingMessagesRef.current = messagesWithoutLast;
markUnsaved();
setIsLoading(true);
setError(null);
const signal = startStreaming();
try {
const response = await generateStoryResponseStream(
story,
messagesWithoutLast.slice(0, -1), // History without the user message we're regenerating for
lastUserMessage.content,
updateStreamingContent,
playerCharacter || undefined,
tempSession,
signal,
);
flushStreamingContent();
const newAssistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const finalMessages = [...messagesWithoutLast, newAssistantMessage];
// Skip state updates for OOC
const isOoc = lastUserMessage.content.startsWith("[OOC:");
let newWorldState = session.worldState;
if (
!isOoc &&
shouldUpdateWorldState(finalMessages.length, session.worldState)
) {
try {
newWorldState = await updateWorldState(
story,
finalMessages,
session.worldState,
);
} catch (e) {
console.warn("Failed to update world state:", e);
}
}
const finalSession: GameSession = {
...session,
messages: finalMessages,
worldState: newWorldState,
};
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
const currentContent = getLatestContent();
if (currentContent.trim()) {
const partialMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: currentContent,
timestamp: new Date(),
};
const partialSession: GameSession = {
...session,
messages: [...messagesWithoutLast, partialMessage],
};
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
}
} else {
setError(err instanceof Error ? err.message : "Произошла ошибка");
setSession(session); // Restore original
}
} finally {
setIsLoading(false);
resetStreaming();
pendingMessagesRef.current = [];
}
};
// Erase: remove last message pair (user + assistant)
const handleErase = async () => {
if (!story || !session || !currentSessionId || isLoading) return;
if (session.messages.length < 2) return;
const messages = session.messages;
let removeCount = 0;
// Remove last assistant if exists
if (messages[messages.length - 1].role === "assistant") {
removeCount++;
// Also remove the user message before it
if (
messages.length >= 2 &&
messages[messages.length - 2].role === "user"
) {
removeCount++;
}
} else if (messages[messages.length - 1].role === "user") {
// Just remove the user message
removeCount = 1;
}
if (removeCount === 0) return;
const newMessages = messages.slice(0, -removeCount);
const newSession: GameSession = {
...session,
messages: newMessages,
};
try {
await apiSaveSession(story.id, currentSessionId, newSession);
setSession(newSession);
markUnsaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка удаления");
}
};
// Continue: продолжить генерацию истории (дополняет последнее сообщение ИИ)
const handleContinue = async () => {
if (isLoading || !session || !story || !currentSessionId) return;
if (session.messages.length === 0) return;
// Find last assistant message
const messages = session.messages;
let lastAssistantIndex = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIndex = i;
break;
}
}
if (lastAssistantIndex === -1) return; // No assistant message to continue
const lastAssistantMessage = messages[lastAssistantIndex];
setIsLoading(true);
setIsContinuing(true);
setError(null);
const signal = startStreaming();
try {
// Generate continuation without adding user message
// Pass special instruction to continue from where AI left off
const response = await generateStoryResponseStream(
story,
messages.slice(0, lastAssistantIndex + 1), // History INCLUDING the message we're continuing
"[CONTINUE]", // Special marker for continuation
updateStreamingContent,
playerCharacter || undefined,
{ ...session, messages: messages.slice(0, lastAssistantIndex + 1) },
signal,
);
flushStreamingContent();
// Append to existing assistant message
const updatedAssistantMessage: ChatMessage = {
...lastAssistantMessage,
content: lastAssistantMessage.content + "\n\n" + response,
};
const finalMessages = [
...messages.slice(0, lastAssistantIndex),
updatedAssistantMessage,
...messages.slice(lastAssistantIndex + 1),
];
const finalSession: GameSession = {
...session,
messages: finalMessages,
};
await apiSaveSession(story.id, currentSessionId, finalSession);
setSession(finalSession);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
const currentContent = getLatestContent();
if (currentContent.trim()) {
// Still append partial content
const updatedAssistantMessage: ChatMessage = {
...lastAssistantMessage,
content: lastAssistantMessage.content + "\n\n" + currentContent,
};
const partialMessages = [
...messages.slice(0, lastAssistantIndex),
updatedAssistantMessage,
...messages.slice(lastAssistantIndex + 1),
];
const partialSession: GameSession = {
...session,
messages: partialMessages,
};
await apiSaveSession(story.id, currentSessionId, partialSession);
setSession(partialSession);
}
} else {
setError(err instanceof Error ? err.message : "Произошла ошибка");
}
} finally {
setIsLoading(false);
setIsContinuing(false);
resetStreaming();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -525,6 +787,25 @@ export default function GamePage() {
}
}, 5000);
// Reset worldState — it's now stale (was calculated from future messages)
let recalculatedWorldState: WorldState | undefined = undefined;
try {
recalculatedWorldState = await updateWorldState(
story,
messagesUpToEdit,
undefined, // pass undefined — force fresh calculation, ignore old state
);
} catch (e) {
console.warn("WorldState recalculation failed on edit:", e);
// Continue without worldState — better than using stale future state
}
const sessionForEdit: GameSession = {
...session,
messages: messagesUpToEdit,
worldState: recalculatedWorldState,
};
try {
const response = await generateStoryResponseStream(
story,
@@ -532,7 +813,7 @@ export default function GamePage() {
editContent.trim(),
updateStreamingContent,
playerCharacter || undefined,
session,
sessionForEdit,
signal,
);
flushStreamingContent();
@@ -562,14 +843,14 @@ export default function GamePage() {
assistantMessage,
];
// Update world state after edit regeneration
let newWorldState = session.worldState;
if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
// Update world state after edit regeneration (based on recalculated state)
let newWorldState = recalculatedWorldState;
if (shouldUpdateWorldState(allMessages.length, recalculatedWorldState)) {
try {
newWorldState = await updateWorldState(
story,
allMessages,
session.worldState,
recalculatedWorldState,
);
} catch (e) {
console.warn("Failed to update world state:", e);
@@ -789,107 +1070,149 @@ export default function GamePage() {
Загрузить ещё {hiddenCount} сообщений
</button>
)}
{visibleMessages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
{editingMessageId === message.id ? (
<div className="message-edit-form">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="message-edit-textarea"
autoFocus
/>
<div className="message-edit-actions">
<button
className="edit-cancel-btn"
onClick={handleCancelEdit}
>
Отмена
</button>
<button
className="edit-save-btn"
onClick={() => handleSaveEdit(message.id)}
>
Сохранить и переиграть
</button>
</div>
</div>
) : (
<>
<div className="message-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
<div className="message-footer">
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString(
"ru-RU",
{
hour: "2-digit",
minute: "2-digit",
},
)}
</span>
{message.role === "user" && !isLoading && (
<div className="message-actions">
{message.versions && message.versions.length > 1 && (
<div className="version-switcher">
<button
className="version-btn"
onClick={() =>
handleSwitchVersion(message.id, "prev")
}
>
</button>
<span className="version-indicator">
{(message.activeVersion || 0) + 1}/
{message.versions.length}
</span>
<button
className="version-btn"
onClick={() =>
handleSwitchVersion(message.id, "next")
}
>
</button>
</div>
)}
<button
className="edit-btn"
onClick={() =>
handleEditMessage(message.id, message.content)
}
title="Редактировать"
>
</button>
</div>
)}
</div>
</>
)}
</div>
))}
{/* Filter out pending user message until streaming starts, but NOT during Continue */}
{(isLoading && !streamingContent && !isContinuing
? visibleMessages.slice(0, -1)
: visibleMessages
).map((message, index) => {
// Check if this is the last assistant message
const isLastAssistant =
message.role === "assistant" &&
index === visibleMessages.length - 1;
const isContinuingThis =
isLastAssistant && isContinuing && streamingContent;
{isLoading && streamingContent && (
return (
<div
key={message.id}
className={`message ${message.role}${isLastAssistant ? " last-assistant" : ""}${isContinuingThis ? " continuing" : ""}`}
>
{editingMessageId === message.id ? (
<div className="message-edit-form">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="message-edit-textarea"
autoFocus
/>
<div className="message-edit-actions">
<button
className="edit-cancel-btn"
onClick={handleCancelEdit}
>
Отмена
</button>
<button
className="edit-save-btn"
onClick={() => handleSaveEdit(message.id)}
>
Сохранить и переиграть
</button>
</div>
</div>
) : (
<>
<div className="message-content">
{message.role === "assistant" ? (
<StreamingText
content={
isContinuingThis && streamingContent
? message.content + "\n\n" + streamingContent
: message.content
}
isStreaming={
!!(isContinuingThis && streamingContent)
}
/>
) : (
<ReactMarkdown>{message.content}</ReactMarkdown>
)}
</div>
<div className="message-footer">
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString(
"ru-RU",
{
hour: "2-digit",
minute: "2-digit",
},
)}
</span>
{message.role === "user" && !isLoading && (
<div className="message-actions">
{message.versions &&
message.versions.length > 1 && (
<div className="version-switcher">
<button
className="version-btn"
onClick={() =>
handleSwitchVersion(message.id, "prev")
}
>
</button>
<span className="version-indicator">
{(message.activeVersion || 0) + 1}/
{message.versions.length}
</span>
<button
className="version-btn"
onClick={() =>
handleSwitchVersion(message.id, "next")
}
>
</button>
</div>
)}
<button
className="edit-btn"
onClick={() =>
handleEditMessage(message.id, message.content)
}
title="Редактировать"
>
</button>
</div>
)}
</div>
</>
)}
</div>
);
})}
{isLoading && streamingContent && !isContinuing && (
<div className="message assistant streaming">
<div className="message-content">
<ReactMarkdown>{streamingContent}</ReactMarkdown>
<StreamingText
content={streamingContent}
isStreaming={true}
/>
</div>
</div>
)}
{isLoading && !streamingContent && (
<div className="message assistant loading">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
{isLoading && !streamingContent && !isContinuing && (
<div className="message assistant streaming">
<div className="message-content">
<span className="loading-text">Генерация...</span>
</div>
</div>
)}
{/* Small loader for Continue mode */}
{isLoading && !streamingContent && isContinuing && (
<div className="continue-loader">
<span className="continue-loader-dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
</div>
)}
{error && (
<div className="error-message">
<span> {error}</span>
@@ -909,68 +1232,93 @@ export default function GamePage() {
</button>
)}
<form
className={`input-container${isOocMode ? " ooc-mode" : ""}`}
onSubmit={(e) => {
e.preventDefault();
handleSend();
}}
>
<button
type="button"
className={`ooc-btn${isOocMode ? " active" : ""}`}
onClick={() => setIsOocMode(!isOocMode)}
title={
isOocMode ? "Вернуться в игру" : "OOC: написать напрямую ИИ"
}
>
OOC
</button>
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height =
Math.min(e.target.scrollHeight, 150) + "px";
}}
onKeyDown={handleKeyDown}
placeholder={
isOocMode
? "[OOC] Напиши инструкцию для ИИ..."
: "Что ты хочешь сделать?..."
}
disabled={isLoading}
rows={1}
name="chat-input"
autoComplete="off"
autoCorrect="on"
autoCapitalize="sentences"
spellCheck={true}
enterKeyHint="send"
data-form-type="other"
data-lpignore="true"
data-gramm="false"
/>
{isLoading ? (
<button
type="button"
onClick={handleStop}
className="send-btn stop-btn"
{/* Hide input during loading */}
{!isLoading && (
<>
{/* Action buttons: Retry, Erase */}
{session && session.messages.length >= 2 && (
<div className="chat-actions">
<button
type="button"
className="action-btn continue-btn"
onClick={handleContinue}
title="Продолжить историю"
>
Continue
</button>
<button
type="button"
className="action-btn retry-btn"
onClick={handleRetry}
title="Перегенерировать последний ответ"
>
🔄 Retry
</button>
<button
type="button"
className="action-btn erase-btn"
onClick={handleErase}
title="Удалить последнюю пару сообщений"
>
🗑 Erase
</button>
</div>
)}
<form
className={`input-container${isOocMode ? " ooc-mode" : ""}`}
onSubmit={(e) => {
e.preventDefault();
handleSend();
}}
>
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="send-btn"
>
</button>
)}
</form>
<button
type="button"
className={`ooc-btn${isOocMode ? " active" : ""}`}
onClick={() => setIsOocMode(!isOocMode)}
title={
isOocMode ? "Вернуться в игру" : "OOC: написать напрямую ИИ"
}
>
OOC
</button>
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height =
Math.min(e.target.scrollHeight, 150) + "px";
}}
onKeyDown={handleKeyDown}
placeholder={
isOocMode
? "[OOC] Напиши инструкцию для ИИ..."
: "Что ты хочешь сделать?..."
}
disabled={false}
rows={1}
name="chat-input"
autoComplete="off"
autoCorrect="on"
autoCapitalize="sentences"
spellCheck={true}
enterKeyHint="send"
data-form-type="other"
data-lpignore="true"
data-gramm="false"
/>
<button
type="submit"
disabled={!input.trim()}
className="send-btn"
>
</button>
</form>
</>
)}
</div>
</div>
</div>
+230 -42
View File
@@ -11,9 +11,9 @@ import type {
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
// Context settings
const RECENT_MESSAGES_COUNT = 15; // Last N messages for context (increased for better scene continuity)
const RECENT_MESSAGES_COUNT = 20; // Last N messages for context (increased for better continuity)
const SUMMARY_THRESHOLD = 20; // After how many messages to generate summary
const WORLD_STATE_UPDATE_INTERVAL = 5; // Update world state every N messages
const WORLD_STATE_UPDATE_INTERVAL = 3; // Update world state every N messages (reduced for better continuity)
interface DeepSeekMessage {
role: "system" | "user" | "assistant";
@@ -37,12 +37,12 @@ interface DeepSeekResponse {
export async function sendMessage(
messages: DeepSeekMessage[],
temperature: number = 0.8,
max_tokens: number = 1000,
max_tokens: number = 2000,
): Promise<string> {
const response = await fetch(`${API_BASE}/api/deepseek/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/json; charset=utf-8",
},
credentials: "include",
body: JSON.stringify({
@@ -82,13 +82,13 @@ export async function sendMessageStream(
const response = await fetch(`${API_BASE}/api/deepseek/chat/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/json; charset=utf-8",
},
credentials: "include",
body: JSON.stringify({
messages,
temperature,
max_tokens: 1000,
max_tokens: 3000,
}),
signal,
});
@@ -110,31 +110,55 @@ export async function sendMessageStream(
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
const decoder = new TextDecoder("utf-8");
let fullContent = "";
let buffer = ""; // Buffer for incomplete lines
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n").filter((line) => line.trim() !== "");
// Append new data to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete lines only
const lines = buffer.split("\n");
// Keep last incomplete line in buffer
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data: ")) continue;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content || "";
if (content) {
fullContent += content;
onChunk(content);
}
} catch {
// Ignore parse errors
const data = trimmed.slice(6);
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content || "";
if (content) {
fullContent += content;
onChunk(content);
}
} catch {
// Incomplete JSON - will be handled in next chunk
}
}
}
// Process any remaining buffer
if (buffer.trim().startsWith("data: ")) {
const data = buffer.trim().slice(6);
if (data !== "[DONE]") {
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content || "";
if (content) {
fullContent += content;
onChunk(content);
}
} catch {
// Ignore final parse error
}
}
}
@@ -170,9 +194,32 @@ This is an adult story. Restrictions lifted for:
NSFW RULES:
1. Describe scenes in detail, but artistically
2. Violence must have consequences and weight
3. Intimate scenes only on player's initiative
4. Do not censor what happens
5. Maintain the atmosphere and tone of the story
3. Do not censor what happens
4. Maintain the atmosphere and tone of the story
CONTENT INITIATION RULES:
- Violence, blood, death, dark themes — AI can and should initiate these naturally
when the story calls for it. These are part of the narrative, not player-triggered.
INITIATING INTIMATE SCENES:
- When the story context clearly leads to intimacy (flirtation established,
characters alone, player makes a direct move or proposition) — transition
into the scene naturally. Do NOT ask for confirmation. Do NOT stall with
questions. The context is the consent.
- "Are you sure?", "What do you want to do?", "She waits for your answer"
during an obvious intimate moment — FORBIDDEN.
CHARACTER INTEGRITY DURING INTIMATE SCENES:
- A character's personality defines HOW they engage in intimacy, not WHETHER.
- Cold and dominant characters (like Азул) do not become submissive or
confessional. They take control. They set the terms. They act first.
- Vulnerable admissions and character breaks require sessions of development —
not one exchange. A queen who "rules through inevitability" doesn't say
"я твоя" after one conversation.
- Show the character's nature THROUGH the intimate scene:
Азул doesn't surrender — she decides. She moves first. She sets the pace.
Her coldness becomes precision. Her control becomes dominance.
That IS the scene. Not her breaking down.
`
: "";
@@ -199,7 +246,8 @@ LANGUAGE: ${story.language}
GENRE: ${story.genre.join(", ")}
SETTING: ${settingInfo}
IMPORTANT: Respond ONLY in language: ${story.language}. Use proper grammar and spelling.
IMPORTANT: Respond ONLY in language: ${story.language}.
CRITICAL: Write every word COMPLETELY. No truncated or cut-off words. Perfect grammar and spelling required. Double-check every word for typos.
=== PLAYER CHARACTER ===
Name: ${player?.name || "Hero"}
@@ -223,6 +271,8 @@ ${nsfwBlock}
6. Do not assume what player wants to do next
7. No time skips without explicit indication
8. If action not written by player — it did NOT happen
9. NEVER change location without explicit player action — if player is in a room, stay in that room
10. Keep track of where each character is — characters cannot teleport between scenes
=== PROTAGONIST HANDLING ===
Guidelines for the main character (MC):
@@ -255,6 +305,7 @@ RIGHT: **"Я ему не доверяю,"** пробормотала она.
Descriptions and narration — plain text without any asterisks.
${oocRules}
Respond in language: ${story.language}
CRITICAL: Write every word COMPLETELY. No truncated or cut-off words. Perfect grammar and spelling required!
=== PLAYER CHARACTER ===
Name: ${player?.name || "Hero"}
@@ -267,8 +318,26 @@ Description: ${playerDescription}`;
export function buildWorldContext(story: Story): string {
const charactersInfo =
story.characters.length > 0
? story.characters
.map((c) => `- ${c.name} (${c.role}): ${c.description}`)
? `CHARACTER PORTRAYAL IS LAW — not suggestion. Every action, reaction, ` +
`word and silence must match the description exactly. ` +
`A character's description overrides all genre tropes and archetypes. ` +
`If described as cold — they NEVER monologue about hero's boldness. ` +
`If described as ruthless — they don't pause dramatically to evaluate him. ` +
`Portray exactly what is written, never what is "typical" for this archetype.\n\n` +
`FORBIDDEN CLICHÉS (NEVER USE):\n` +
`- "Ты либо очень смелый, либо очень глупый" / "You're either very brave or very foolish"\n` +
`- "Интересно..." / "Interesting..." as a reaction to boldness\n` +
`- "Ты дерзкий, мне это нравится" / "You're bold, I like that"\n` +
`- "Никто никогда не осмеливался..." / "No one has ever dared..."\n` +
`- "Вопрос только — как именно я его запомню" / "The only question is how I'll remember him"\n` +
`- Dramatic pauses to evaluate the hero's courage\n` +
`- Surprised reactions to player's "bravery" or "audacity"\n` +
`These are lazy AI patterns. Characters simply ACT according to their nature.\n\n` +
story.characters
.map(
(c) =>
`- ${c.name} (${c.role}, PORTRAY EXACTLY AS DESCRIBED): ${c.description}`,
)
.join("\n")
: "Not specified";
@@ -279,6 +348,10 @@ Description: ${story.world.description}
World rules: ${story.world.rules.join("; ")}
=== WORLD CHARACTERS ===
Character roles indicate their NARRATIVE function, not relationship status:
- "Романс" / "Romance" = potential love interest, NOT already in relationship
- "Антагонист" / "Antagonist" = story adversary, not necessarily evil
- Relationships must develop through actual story events
${charactersInfo}
=== MAIN PLOT ===
@@ -295,7 +368,7 @@ export function buildDynamicContext(
const state = session.currentState;
const summary = session.storySummary || "The story just began.";
const keyEvents = session.keyEvents?.length
? session.keyEvents.slice(-5).join("\n- ")
? session.keyEvents.slice(-7).join("\n- ")
: "No significant events yet.";
// World state for character tracking
@@ -312,8 +385,9 @@ export function buildDynamicContext(
• Do NOT ask "What do you do?" — end with atmosphere, not questions
• Format dialogue: **"text"** (double asterisks = bold)
• React to player's words explicitly
Use proper grammar and spelling in the story language
• Characters can only be where they logically should be based on their last known location`
CRITICAL: Write every word COMPLETELY. No truncated words. Perfect grammar required!
• Characters can only be where they logically should be based on their last known location
• RESPECT THE SUMMARY BELOW - it contains crucial story info you may not see in recent messages`
: "";
return `
@@ -322,7 +396,10 @@ Health: ${state.health}%
Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
${worldStateContext}
=== STORY SUMMARY ===
=== STORY SUMMARY (IMPORTANT - THIS IS YOUR MEMORY) ===
The following summary contains CRITICAL information about past events, characters, promises, and relationships.
You MUST use this info to maintain story consistency:
${summary}
=== KEY EVENTS ===
@@ -366,13 +443,19 @@ export async function generateStoryResponse(
// Build final system prompt
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;
// Build scene anchor from world state (last thing model sees before generating)
const sceneAnchor = buildSceneAnchor(session?.worldState);
const finalUserMessage = sceneAnchor
? `${userMessage}\n\n${sceneAnchor}`
: userMessage;
const messages: DeepSeekMessage[] = [
{ role: "system", content: systemPrompt },
...recentMessages.map((msg) => ({
role: msg.role as "user" | "assistant",
content: msg.content,
})),
{ role: "user", content: userMessage },
{ role: "user", content: finalUserMessage },
];
// Use temperature from story settings (default 0.9 for balanced creative writing)
@@ -399,13 +482,67 @@ export async function generateStoryResponseStream(
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext;
// Handle [CONTINUE] - AI should continue from last message
const isContinuation = userMessage === "[CONTINUE]";
// Build scene anchor from world state (last thing model sees before generating)
const sceneAnchor = buildSceneAnchor(session?.worldState);
let finalUserMessage: string;
if (isContinuation) {
// For continuation, get the last assistant message content to continue from
const lastAssistantContent =
session?.messages
?.slice()
.reverse()
.find((m) => m.role === "assistant")?.content || "";
// Extract last ~500 chars for context - get the ending
const continuationContext = lastAssistantContent.slice(-500);
// Get the very last sentence/phrase to make it crystal clear
const lastSentence =
lastAssistantContent
.split(/[.!?»"]\s*/)
.filter((s) => s.trim())
.pop() || "";
finalUserMessage = `[SYSTEM INSTRUCTION - CONTINUATION MODE]
You MUST continue the narrative EXACTLY from where it stopped.
CRITICAL RULES:
1. DO NOT repeat ANY part of what was already written
2. DO NOT start with the same words or phrases
3. Pick up MID-THOUGHT or MID-ACTION if that's where it stopped
4. Write NEW content only - as if you're typing the next paragraph
The story currently ends with:
"...${continuationContext}"
The very last phrase was: "${lastSentence}..."
NOW CONTINUE FROM HERE - write what happens NEXT, not what already happened.
${sceneAnchor ? `\n${sceneAnchor}` : ""}`;
} else {
finalUserMessage = sceneAnchor
? `${userMessage}\n\n${sceneAnchor}`
: userMessage;
}
// Truncate very long messages to avoid API limits (50k chars max per message)
const MAX_MESSAGE_LENGTH = 40000;
const truncatedMessages = recentMessages.map((msg) => ({
role: msg.role as "user" | "assistant",
content:
msg.content.length > MAX_MESSAGE_LENGTH
? msg.content.slice(-MAX_MESSAGE_LENGTH) // Keep the END (most recent part)
: msg.content,
}));
const messages: DeepSeekMessage[] = [
{ role: "system", content: systemPrompt },
...recentMessages.map((msg) => ({
role: msg.role as "user" | "assistant",
content: msg.content,
})),
{ role: "user", content: userMessage },
...truncatedMessages,
{ role: "user", content: finalUserMessage },
];
return sendMessageStream(messages, story.temperature || 0.9, onChunk, signal);
@@ -443,6 +580,18 @@ export async function generateStorySummary(
- What character ALREADY said or did (brief):
- What character KNOWS and DOESN'T KNOW (brief):`;
const importantReminder = `
CRITICAL - PRESERVE THESE DETAILS:
- All promises, deals, agreements made by ANY character
- Character deaths, injuries, or status changes
- Romantic developments (confessions, kisses, relationship progress)
- Items given, received, or lost
- Locations visited and current location
- Names of ALL characters who appeared
- Any secrets revealed or discovered
- Character motivations and goals
- Unresolved conflicts or tensions`;
const prompt = previousSummary
? `Update the story summary with new events.
@@ -451,26 +600,38 @@ ${previousSummary}
NEW EVENTS:
${conversationText}
${importantReminder}
Write an updated summary in the following format:
=== GENERAL SUMMARY ===
Briefly (3-4 sentences): key events, hero's location, important decisions.
(5-7 sentences): key events, hero's current location, important decisions, ongoing conflicts.
=== IMPORTANT FACTS ===
- List any promises/agreements made
- List any items gained/lost
- List current goals/quests
=== CHARACTER CARDS ===
For EACH character that appeared in the story, fill out a card:
${characterTemplate}
Update character info based on new events. Maintain consistency.
NEVER remove character info from previous summary. Only add new info or update existing.
Write the summary in language: ${story.language}`
: `Create a summary of this story's events:
${conversationText}
${importantReminder}
Write the summary in the following format:
=== GENERAL SUMMARY ===
Briefly (3-4 sentences): what happened, hero's location, important decisions.
(5-7 sentences): what happened, hero's location, important decisions, ongoing conflicts.
=== IMPORTANT FACTS ===
- List any promises/agreements made
- List any items gained/lost
- List current goals/quests
=== CHARACTER CARDS ===
For EACH character that appeared in the story, fill out a card:
@@ -482,12 +643,22 @@ Write the summary in language: ${story.language}`;
const summaryMessages: DeepSeekMessage[] = [
{
role: "system",
content: `You are a story summary assistant. Write concisely and to the point. Pay special attention to romantic storylines and character relationships — this is important for story consistency. Fill character cards only based on what actually happened in the story. Output in language: ${story.language}`,
content: `You are a story summary assistant. Your summaries are CRITICAL for story continuity - the main AI RELIES on them to remember what happened.
PRIORITIES:
1. Character relationships and romantic developments
2. Promises, deals, agreements - these MUST be preserved
3. Character deaths or major status changes
4. Items, abilities, resources
5. Current location and situation
Never omit important details. If previous summary had info about a character, KEEP that info and add new details.
Output in language: ${story.language}`,
},
{ role: "user", content: prompt },
];
return sendMessage(summaryMessages, 0.3);
return sendMessage(summaryMessages, 0.1); // Low temp for factual, concise summary
}
/**
@@ -706,3 +877,20 @@ Situation: ${currentScene.situation}`;
return result;
}
/**
* Builds a brief scene anchor to append to user message
* This is the LAST thing the model sees before generating, preventing location drift
*/
export function buildSceneAnchor(worldState?: WorldState): string {
if (!worldState?.currentScene) return "";
const { location, presentCharacters, situation } = worldState.currentScene;
const present =
presentCharacters.length > 0
? presentCharacters.join(", ")
: "no one nearby";
return `[SCENE: ${location} | Present: ${present} | ${situation}. DO NOT change location without player movement action.]`;
}
+127 -4
View File
@@ -55,11 +55,36 @@ async function translateToEnglish(text: string): Promise<string> {
}
/**
* Generates an image from a prompt (portrait format)
* Extracts UUID from GeminiGen image URL
* URL format: https://api.geminigen.ai/storage/<uuid>.png or similar
*/
function extractUuidFromUrl(url: string): string | null {
if (!url) return null;
// Try to extract UUID pattern from URL
const uuidPattern =
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
const match = url.match(uuidPattern);
return match ? match[1] : null;
}
/**
* Generates an image from a prompt (portrait format by default)
* Returns image URL
*/
async function generateImageFromPrompt(prompt: string): Promise<string> {
console.log("Generating image with prompt:", prompt);
async function generateImageFromPrompt(
prompt: string,
orientation: "portrait" | "landscape" = "portrait",
refHistory?: string,
): Promise<string> {
console.log(
"Generating image with prompt:",
prompt,
"orientation:",
orientation,
"refHistory:",
refHistory,
);
const response = await fetch(`${API_BASE}/api/generate-image`, {
method: "POST",
@@ -67,7 +92,7 @@ async function generateImageFromPrompt(prompt: string): Promise<string> {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ prompt }),
body: JSON.stringify({ prompt, orientation, refHistory }),
});
if (!response.ok) {
@@ -132,6 +157,104 @@ export async function generateAvatarUrl(
return generateImageFromPrompt(prompt);
}
interface BannerCharacter {
name: string;
avatarUrl?: string;
}
interface GenerateBannerOptions {
title: string;
description: string;
genre?: string[];
setting?: string[];
isNsfw?: boolean;
customPrompt?: string;
characters?: BannerCharacter[]; // Персонажи истории для референса
}
/**
* Finds a character mentioned in the story description/title that has an avatar
* Returns the character's avatar UUID if found
*/
function findCharacterReference(
title: string,
description: string,
characters: BannerCharacter[],
): string | null {
if (!characters || characters.length === 0) return null;
const searchText = `${title} ${description}`.toLowerCase();
for (const char of characters) {
if (!char.name || !char.avatarUrl) continue;
// Check if character name is mentioned in title/description
const nameLower = char.name.toLowerCase();
if (searchText.includes(nameLower)) {
const uuid = extractUuidFromUrl(char.avatarUrl);
if (uuid) {
console.log(`Found character reference: ${char.name} -> ${uuid}`);
return uuid;
}
}
}
return null;
}
/**
* Generates a banner/cover image for a story (landscape 16:9 format)
* If a character from the story is mentioned and has an avatar, uses it as reference
* Otherwise generates a landscape/environment scene without characters
* Returns image URL
*/
export async function generateBannerUrl(
options: GenerateBannerOptions,
): Promise<string> {
const {
title,
description,
genre = [],
setting = [],
isNsfw,
customPrompt,
characters = [],
} = options;
// If custom prompt provided - use it directly
if (customPrompt && customPrompt.trim()) {
return generateImageFromPrompt(customPrompt.trim(), "landscape");
}
// Check if any character with avatar is mentioned in description
const charRefUuid = findCharacterReference(title, description, characters);
// Build automatic prompt from story info
const genreText = genre.length > 0 ? genre.slice(0, 3).join(", ") : "";
const settingText = setting.length > 0 ? setting.slice(0, 2).join(", ") : "";
// Get first 150 chars of description for context
const shortDesc = description
.slice(0, 150)
.replace(/\{user\}/gi, "hero")
.trim();
const textToTranslate = `${title} - ${shortDesc}${settingText ? `, setting: ${settingText}` : ""}`;
const englishDesc = await translateToEnglish(textToTranslate);
const nsfwTag = isNsfw ? "nsfw, mature, " : "";
const genreStyle = genreText ? `${genreText} style, ` : "";
// If no character reference - generate landscape without people
const noCharactersTag = charRefUuid
? ""
: "landscape scene, environment only, no people, no characters, ";
const prompt = `${nsfwTag}anime illustration, cinematic wide shot, ${noCharactersTag}${genreStyle}epic scene depicting: ${englishDesc}, dramatic lighting, atmospheric, detailed background, masterpiece, best quality, highly detailed, vibrant colors, 16:9 banner composition`;
return generateImageFromPrompt(prompt, "landscape", charRefUuid || undefined);
}
/**
* Poll for image generation result
*/
+1
View File
@@ -10,6 +10,7 @@ export interface Character {
age?: CharacterAge; // возраст персонажа
gender?: CharacterGender; // пол персонажа
avatarUrl?: string; // URL аватара персонажа
saveAsGlobal?: boolean; // сохранить как глобального NPC
}
// NPC персонаж (сохранённый в БД)