Compare commits

..

4 Commits

Author SHA1 Message Date
Alexej Wolff 96432d22f5 chore: remove deploy.ps1 from repo, add to gitignore 2026-05-07 00:39:01 +02:00
Alexej Wolff a4cbc2db86 fix: textarea resize vertical only, deploy script npm install 2026-05-07 00:37:54 +02:00
Alexej Wolff 49f2b9261d feat: auto-save story characters as global NPCs on save 2026-05-07 00:32:51 +02:00
Alexej Wolff 56e78053cf fix: restore messages variable in deepseek endpoints 2026-05-07 00:25:44 +02:00
10 changed files with 285 additions and 1590 deletions
+27 -56
View File
@@ -198,16 +198,18 @@ function validateDeepSeekRequest(body) {
const errors = []; const errors = [];
const { messages, temperature, max_tokens } = body; const { messages, temperature, max_tokens } = body;
// Validate messages - only check critical errors, truncation handled in sanitize // Validate messages
if (!Array.isArray(messages)) { if (!Array.isArray(messages)) {
errors.push("messages must be an array"); errors.push("messages must be an array");
} else { } else {
if (messages.length === 0) { if (messages.length === 0) {
errors.push("messages cannot be empty"); errors.push("messages cannot be empty");
} }
// Don't error on too many messages - we'll truncate them if (messages.length > DEEPSEEK_LIMITS.MAX_MESSAGES) {
// Don't error on too long messages - we'll truncate them errors.push(`too many messages (max ${DEEPSEEK_LIMITS.MAX_MESSAGES})`);
}
let totalLength = 0;
for (let i = 0; i < messages.length; i++) { for (let i = 0; i < messages.length; i++) {
const msg = messages[i]; const msg = messages[i];
if (!msg || typeof msg !== "object") { if (!msg || typeof msg !== "object") {
@@ -221,7 +223,20 @@ function validateDeepSeekRequest(body) {
} }
if (typeof msg.content !== "string") { if (typeof msg.content !== "string") {
errors.push(`messages[${i}].content must be a 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)`,
);
} }
} }
@@ -249,37 +264,8 @@ function validateDeepSeekRequest(body) {
return errors; 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) { function sanitizeDeepSeekMessages(messages) {
// First truncate to fit limits return messages.map((msg) => ({
const truncatedMessages = truncateMessagesToFit(messages);
return truncatedMessages.map((msg) => ({
role: msg.role, role: msg.role,
content: String(msg.content), content: String(msg.content),
})); }));
@@ -1073,7 +1059,7 @@ app.get("/api/admin/stats", requireAuth, async (req, res) => {
// Прокси для генерации изображений через Grok (обход CORS) // Прокси для генерации изображений через Grok (обход CORS)
app.post("/api/generate-image", requireAuth, async (req, res) => { app.post("/api/generate-image", requireAuth, async (req, res) => {
try { try {
const { prompt, orientation = "portrait", refHistory } = req.body; const { prompt } = req.body;
const apiKey = process.env.GEMINIGEN_API_KEY; const apiKey = process.env.GEMINIGEN_API_KEY;
if (!apiKey) { if (!apiKey) {
@@ -1082,27 +1068,14 @@ app.post("/api/generate-image", requireAuth, async (req, res) => {
.json({ error: "GeminiGen API key not configured" }); .json({ error: "GeminiGen API key not configured" });
} }
console.log( console.log("Generating image with Grok, prompt:", prompt);
"Generating image with Grok, prompt:",
prompt,
"orientation:",
orientation,
"refHistory:",
refHistory,
);
// Используем FormData для multipart/form-data // Используем FormData для multipart/form-data
const formData = new FormData(); const formData = new FormData();
formData.append("prompt", prompt); formData.append("prompt", prompt);
formData.append("orientation", orientation); // portrait (9:16) or landscape (16:9) formData.append("orientation", "portrait"); // 9:16
formData.append("num_result", "1"); formData.append("num_result", "1");
// Добавляем референс истории если есть (для консистентности персонажей)
if (refHistory) {
formData.append("ref_history", refHistory);
console.log("Using ref_history:", refHistory);
}
const response = await fetch( const response = await fetch(
"https://api.geminigen.ai/uapi/v1/imagen/grok", "https://api.geminigen.ai/uapi/v1/imagen/grok",
{ {
@@ -1244,7 +1217,7 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
const response = await fetch(DEEPSEEK_API_URL, { const response = await fetch(DEEPSEEK_API_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -1252,7 +1225,6 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
messages: sanitizedMessages, messages: sanitizedMessages,
temperature, temperature,
max_tokens: clampedMaxTokens, max_tokens: clampedMaxTokens,
top_p: 0.95,
}), }),
}); });
@@ -1306,7 +1278,7 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
const response = await fetch(DEEPSEEK_API_URL, { const response = await fetch(DEEPSEEK_API_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
@@ -1314,7 +1286,6 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
messages: sanitizedMessages, messages: sanitizedMessages,
temperature, temperature,
max_tokens: clampedMaxTokens, max_tokens: clampedMaxTokens,
top_p: 0.95,
stream: true, stream: true,
}), }),
}); });
@@ -1326,13 +1297,13 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
} }
// Set headers for SSE // Set headers for SSE
res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive"); res.setHeader("Connection", "keep-alive");
// Pipe the stream // Pipe the stream
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder();
const pump = async () => { const pump = async () => {
try { try {
@@ -1395,7 +1366,7 @@ app.post("/api/deepseek/translate", requireAuth, async (req, res) => {
const response = await fetch(DEEPSEEK_API_URL, { const response = await fetch(DEEPSEEK_API_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
-91
View File
@@ -1,91 +0,0 @@
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,4 +8,3 @@ export {
export { ChatInput } from "./ChatInput"; export { ChatInput } from "./ChatInput";
export { SessionSelector } from "./SessionSelector"; export { SessionSelector } from "./SessionSelector";
export { CharacterPanel } from "./CharacterPanel"; export { CharacterPanel } from "./CharacterPanel";
export { default as StreamingText } from "./StreamingText";
-138
View File
@@ -714,27 +714,6 @@
border-color: #667eea; 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 { .hint {
display: block; display: block;
@@ -1134,120 +1113,3 @@
justify-content: center; 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;
}
}
+5 -115
View File
@@ -9,7 +9,7 @@ import {
getNPCCharacters, getNPCCharacters,
createNPCCharacter, createNPCCharacter,
} from "../services/api"; } from "../services/api";
import { generateAvatarUrl, generateBannerUrl } from "../services/imageGen"; import { generateAvatarUrl } from "../services/imageGen";
import { useStoryGeneration } from "../hooks/useStoryGeneration"; import { useStoryGeneration } from "../hooks/useStoryGeneration";
import type { import type {
Character, Character,
@@ -92,8 +92,6 @@ export default function CreateStoryPage() {
const [generatingAvatarIndex, setGeneratingAvatarIndex] = useState< const [generatingAvatarIndex, setGeneratingAvatarIndex] = useState<
number | null number | null
>(null); >(null);
const [generatingBanner, setGeneratingBanner] = useState(false);
const [coverImage, setCoverImage] = useState("");
const [savedNPCs, setSavedNPCs] = useState<NPCCharacter[]>([]); const [savedNPCs, setSavedNPCs] = useState<NPCCharacter[]>([]);
const [showNPCSelector, setShowNPCSelector] = useState(false); const [showNPCSelector, setShowNPCSelector] = useState(false);
@@ -156,7 +154,6 @@ export default function CreateStoryPage() {
const story = await getStory(id); const story = await getStory(id);
if (story) { if (story) {
setCoverImage(story.coverImage || "");
setForm({ setForm({
title: story.title, title: story.title,
description: story.description || "", description: story.description || "",
@@ -277,7 +274,7 @@ export default function CreateStoryPage() {
const handleCharacterChange = ( const handleCharacterChange = (
index: number, index: number,
field: keyof Character, field: keyof Character,
value: string | boolean, value: string,
) => { ) => {
const newCharacters = [...form.characters]; const newCharacters = [...form.characters];
newCharacters[index] = { ...newCharacters[index], [field]: value }; newCharacters[index] = { ...newCharacters[index], [field]: value };
@@ -385,37 +382,6 @@ 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 handleRuleChange = (index: number, value: string) => {
const newRules = [...form.worldRules]; const newRules = [...form.worldRules];
@@ -464,7 +430,7 @@ export default function CreateStoryPage() {
const storyData = { const storyData = {
title: form.title, title: form.title,
description: form.description || `Исекай история: ${form.title}`, description: form.description || `Исекай история: ${form.title}`,
coverImage: coverImage, coverImage: "",
language: language:
LANGUAGES.find((l) => l.code === form.language)?.name.split(" ")[1] || LANGUAGES.find((l) => l.code === form.language)?.name.split(" ")[1] ||
"Русский", "Русский",
@@ -488,10 +454,7 @@ export default function CreateStoryPage() {
const saveCharactersAsGlobalNPCs = async () => { const saveCharactersAsGlobalNPCs = async () => {
const existingNames = savedNPCs.map((npc) => npc.name.toLowerCase()); const existingNames = savedNPCs.map((npc) => npc.name.toLowerCase());
// Only save characters that have saveAsGlobal flag enabled for (const char of storyCharacters) {
const charsToSave = storyCharacters.filter((c) => c.saveAsGlobal);
for (const char of charsToSave) {
// Skip if NPC with this name already exists // Skip if NPC with this name already exists
if (existingNames.includes(char.name.toLowerCase())) { if (existingNames.includes(char.name.toLowerCase())) {
continue; continue;
@@ -656,10 +619,9 @@ export default function CreateStoryPage() {
</p> </p>
<div className="temperature-selector"> <div className="temperature-selector">
{[ {[
{ value: 0.75, label: "🎯 Точный", desc: "0.75" },
{ value: 0.9, label: "⚖️ Сбалансированный", desc: "0.9" }, { value: 0.9, label: "⚖️ Сбалансированный", desc: "0.9" },
{ value: 0.95, label: "✨ Живой", desc: "0.95" }, { value: 0.95, label: "✨ Живой", desc: "0.95" },
{ value: 1.0, label: "🎨 Креативный", desc: "1.0" }, { value: 1.05, label: "🎨 Креативный", desc: "1.05" },
].map((opt) => ( ].map((opt) => (
<button <button
key={opt.value} key={opt.value}
@@ -838,64 +800,6 @@ export default function CreateStoryPage() {
</div> </div>
</section> </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"> <section className="form-section">
<h2> <h2>
@@ -1142,20 +1046,6 @@ export default function CreateStoryPage() {
placeholder="Описание персонажа..." placeholder="Описание персонажа..."
rows={2} 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> </div>
</div> </div>
+29 -305
View File
@@ -14,39 +14,6 @@
touch-action: none; 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 * { .game-page * {
touch-action: pan-y; touch-action: pan-y;
} }
@@ -276,7 +243,6 @@
overflow: hidden; overflow: hidden;
position: relative; position: relative;
width: 100%; width: 100%;
align-items: center;
} }
.scroll-to-bottom-btn { .scroll-to-bottom-btn {
@@ -312,13 +278,12 @@
overscroll-behavior: contain; overscroll-behavior: contain;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
touch-action: pan-y; touch-action: pan-y;
padding: 1.5rem 2rem; padding: 1rem;
padding-left: max(2rem, env(safe-area-inset-left)); padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(2rem, env(safe-area-inset-right)); padding-right: max(1rem, env(safe-area-inset-right));
padding-bottom: 1rem; padding-bottom: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
gap: 0.75rem; gap: 0.75rem;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
@@ -347,11 +312,10 @@
} }
.message { .message {
max-width: 750px; max-width: 88%;
animation: fadeIn 0.25s ease; animation: fadeIn 0.25s ease;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
width: 100%;
} }
@keyframes fadeIn { @keyframes fadeIn {
@@ -366,49 +330,31 @@
} }
.message.user { .message.user {
/* No align-self - inherit center from parent */ align-self: flex-end;
} }
.message.assistant { .message.assistant {
/* No align-self - inherit center from parent */ align-self: flex-start;
} }
.message-content { .message-content {
padding: 0.5rem 0; padding: 0.875rem 1rem;
line-height: 1.8; border-radius: 18px;
font-size: 1.05rem; line-height: 1.7;
text-align: justify; font-size: 1.1rem;
text-justify: inter-word;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.message-content { .message-content {
font-size: 1rem; font-size: 22px;
line-height: 1.75; line-height: 1.8;
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 { .message.user .message-content {
background: rgba(255, 255, 255, 0.03); background: #2563eb;
color: #9ca3af; color: white;
border-left: 2px solid #3b82f6; border-bottom-right-radius: 6px;
padding: 0.75rem 1rem 0.75rem 1rem;
text-align: left;
border-radius: 0 8px 8px 0;
} }
.message.assistant .message-content { .message.assistant .message-content {
@@ -418,18 +364,12 @@
padding: 0.5rem 0; padding: 0.5rem 0;
} }
/* Loading text */
.loading-text {
color: #9ca3af;
font-style: italic;
}
.message-content p { .message-content p {
margin: 0; margin: 0;
} }
.message-content p + p { .message-content p + p {
margin-top: 0.25rem; margin-top: 0.75rem;
} }
/* Markdown стили */ /* Markdown стили */
@@ -478,10 +418,9 @@
.message-footer { .message-footer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: space-between;
margin-top: 0.25rem; margin-top: 0.25rem;
padding: 0; padding: 0 0.4rem;
gap: 1rem;
} }
.message-time { .message-time {
@@ -490,13 +429,11 @@
} }
.message.user .message-time { .message.user .message-time {
color: #6b7280; color: rgba(255, 255, 255, 0.4);
} }
.message.user .message-footer { .message.user .message-footer {
flex-direction: row; flex-direction: row-reverse;
padding-left: 1.75rem;
margin-top: 0.35rem;
} }
.message-actions { .message-actions {
@@ -702,122 +639,19 @@
border-color: #667eea; 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 { .input-container {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
gap: 0.5rem; gap: 0.5rem;
margin: 0.5rem auto; margin: 0.5rem;
margin-left: max(0.5rem, env(safe-area-inset-left));
margin-right: max(0.5rem, env(safe-area-inset-right));
margin-bottom: max(0.5rem, env(safe-area-inset-bottom)); margin-bottom: max(0.5rem, env(safe-area-inset-bottom));
padding: 0.5rem; padding: 0.5rem;
background: #1a1a1a; background: #1a1a1a;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px; border-radius: 24px;
max-width: 750px;
width: calc(100% - 1rem);
} }
.input-container textarea { .input-container textarea {
@@ -940,64 +774,15 @@
color: #f59e0b; color: #f59e0b;
} }
/* Streaming message animation - simple fade effect */ /* Streaming message animation */
.message.streaming .message-content { .message.streaming .message-text {
position: relative; position: relative;
} }
/* Simple fade-in for streaming text */ .message.streaming .message-text::after {
.message.streaming .message-content, content: "▋";
.message.continuing .message-content { animation: blink 1s infinite;
animation: simpleFadeIn 0.3s ease-out; margin-left: 2px;
}
@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 */ /* Game Layout with Character Panel */
@@ -1227,64 +1012,3 @@
padding: 0.35rem 0.6rem; 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;
}
}
+37 -385
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useParams, Link, useSearchParams } from "react-router-dom"; import { useParams, Link, useSearchParams } from "react-router-dom";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { saveSession as apiSaveSession } from "../services/api"; import { saveSession as apiSaveSession } from "../services/api";
import { import {
@@ -19,7 +18,6 @@ import type {
ChatMessage, ChatMessage,
MessageVersion, MessageVersion,
PlayerCharacter, PlayerCharacter,
WorldState,
} from "../types"; } from "../types";
import { import {
useGameSession, useGameSession,
@@ -27,11 +25,7 @@ import {
useCharacterDetection, useCharacterDetection,
useLazyMessages, useLazyMessages,
} from "../hooks"; } from "../hooks";
import { import { SessionSelector, CharacterPanel } from "../components/game";
SessionSelector,
CharacterPanel,
StreamingText,
} from "../components/game";
import "./GamePage.css"; import "./GamePage.css";
function generateId(): string { function generateId(): string {
@@ -82,7 +76,7 @@ export default function GamePage() {
resetStreaming, resetStreaming,
startStreaming, startStreaming,
abortController, abortController,
// abort - не используется, т.к. кнопка "Остановить" закомментирована abort,
getLatestContent, getLatestContent,
} = useStreamingResponse(); } = useStreamingResponse();
@@ -114,7 +108,6 @@ export default function GamePage() {
// Local state // Local state
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isContinuing, setIsContinuing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showScrollButton, setShowScrollButton] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null); const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
@@ -336,7 +329,7 @@ export default function GamePage() {
const response = await generateStoryResponseStream( const response = await generateStoryResponseStream(
story, story,
session.messages, session.messages,
userMessage.content, // Use userMessage.content to include [OOC: ...] wrapper input.trim(),
updateStreamingContent, updateStreamingContent,
playerCharacter || undefined, playerCharacter || undefined,
session, session,
@@ -354,33 +347,13 @@ export default function GamePage() {
const allMessages = [...updatedMessages, assistantMessage]; const allMessages = [...updatedMessages, assistantMessage];
// Skip state updates for OOC messages - they are meta-conversations const newKeyEvents = await extractKeyEvents(
let newKeyEvents = session.keyEvents || [];
let newSummary = session.storySummary;
let newWorldState = session.worldState;
if (!isOocMode) {
newKeyEvents = await extractKeyEvents(
response, response,
session.keyEvents || [], session.keyEvents || [],
); );
// Calculate total context size let newSummary = session.storySummary;
const totalContextSize = allMessages.reduce( if (allMessages.length % 15 === 0 && allMessages.length > 0) {
(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( newSummary = await generateStorySummary(
story, story,
allMessages, allMessages,
@@ -389,6 +362,7 @@ export default function GamePage() {
} }
// Update world state for better character tracking // Update world state for better character tracking
let newWorldState = session.worldState;
if (shouldUpdateWorldState(allMessages.length, session.worldState)) { if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
try { try {
newWorldState = await updateWorldState( newWorldState = await updateWorldState(
@@ -400,7 +374,6 @@ export default function GamePage() {
console.warn("Failed to update world state:", e); console.warn("Failed to update world state:", e);
} }
} }
}
const finalSession: GameSession = { const finalSession: GameSession = {
...session, ...session,
@@ -444,243 +417,8 @@ export default function GamePage() {
} }
}; };
// Закомментирована кнопка остановки - функция сохранена на будущее const handleStop = () => {
// const handleStop = () => { abort();
// 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) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -787,25 +525,6 @@ export default function GamePage() {
} }
}, 5000); }, 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 { try {
const response = await generateStoryResponseStream( const response = await generateStoryResponseStream(
story, story,
@@ -813,7 +532,7 @@ export default function GamePage() {
editContent.trim(), editContent.trim(),
updateStreamingContent, updateStreamingContent,
playerCharacter || undefined, playerCharacter || undefined,
sessionForEdit, session,
signal, signal,
); );
flushStreamingContent(); flushStreamingContent();
@@ -843,14 +562,14 @@ export default function GamePage() {
assistantMessage, assistantMessage,
]; ];
// Update world state after edit regeneration (based on recalculated state) // Update world state after edit regeneration
let newWorldState = recalculatedWorldState; let newWorldState = session.worldState;
if (shouldUpdateWorldState(allMessages.length, recalculatedWorldState)) { if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
try { try {
newWorldState = await updateWorldState( newWorldState = await updateWorldState(
story, story,
allMessages, allMessages,
recalculatedWorldState, session.worldState,
); );
} catch (e) { } catch (e) {
console.warn("Failed to update world state:", e); console.warn("Failed to update world state:", e);
@@ -1070,23 +789,8 @@ export default function GamePage() {
Загрузить ещё {hiddenCount} сообщений Загрузить ещё {hiddenCount} сообщений
</button> </button>
)} )}
{/* Filter out pending user message until streaming starts, but NOT during Continue */} {visibleMessages.map((message) => (
{(isLoading && !streamingContent && !isContinuing <div key={message.id} className={`message ${message.role}`}>
? 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;
return (
<div
key={message.id}
className={`message ${message.role}${isLastAssistant ? " last-assistant" : ""}${isContinuingThis ? " continuing" : ""}`}
>
{editingMessageId === message.id ? ( {editingMessageId === message.id ? (
<div className="message-edit-form"> <div className="message-edit-form">
<textarea <textarea
@@ -1113,20 +817,7 @@ export default function GamePage() {
) : ( ) : (
<> <>
<div className="message-content"> <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> <ReactMarkdown>{message.content}</ReactMarkdown>
)}
</div> </div>
<div className="message-footer"> <div className="message-footer">
<span className="message-time"> <span className="message-time">
@@ -1140,8 +831,7 @@ export default function GamePage() {
</span> </span>
{message.role === "user" && !isLoading && ( {message.role === "user" && !isLoading && (
<div className="message-actions"> <div className="message-actions">
{message.versions && {message.versions && message.versions.length > 1 && (
message.versions.length > 1 && (
<div className="version-switcher"> <div className="version-switcher">
<button <button
className="version-btn" className="version-btn"
@@ -1180,39 +870,26 @@ export default function GamePage() {
</> </>
)} )}
</div> </div>
); ))}
})}
{isLoading && streamingContent && !isContinuing && ( {isLoading && streamingContent && (
<div className="message assistant streaming"> <div className="message assistant streaming">
<div className="message-content"> <div className="message-content">
<StreamingText <ReactMarkdown>{streamingContent}</ReactMarkdown>
content={streamingContent}
isStreaming={true}
/>
</div> </div>
</div> </div>
)} )}
{isLoading && !streamingContent && !isContinuing && ( {isLoading && !streamingContent && (
<div className="message assistant streaming"> <div className="message assistant loading">
<div className="message-content"> <div className="typing-indicator">
<span className="loading-text">Генерация...</span> <span></span>
<span></span>
<span></span>
</div> </div>
</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 && ( {error && (
<div className="error-message"> <div className="error-message">
<span> {error}</span> <span> {error}</span>
@@ -1232,39 +909,6 @@ export default function GamePage() {
</button> </button>
)} )}
{/* 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 <form
className={`input-container${isOocMode ? " ooc-mode" : ""}`} className={`input-container${isOocMode ? " ooc-mode" : ""}`}
onSubmit={(e) => { onSubmit={(e) => {
@@ -1297,7 +941,7 @@ export default function GamePage() {
? "[OOC] Напиши инструкцию для ИИ..." ? "[OOC] Напиши инструкцию для ИИ..."
: "Что ты хочешь сделать?..." : "Что ты хочешь сделать?..."
} }
disabled={false} disabled={isLoading}
rows={1} rows={1}
name="chat-input" name="chat-input"
autoComplete="off" autoComplete="off"
@@ -1309,6 +953,15 @@ export default function GamePage() {
data-lpignore="true" data-lpignore="true"
data-gramm="false" data-gramm="false"
/> />
{isLoading ? (
<button
type="button"
onClick={handleStop}
className="send-btn stop-btn"
>
</button>
) : (
<button <button
type="submit" type="submit"
disabled={!input.trim()} disabled={!input.trim()}
@@ -1316,9 +969,8 @@ export default function GamePage() {
> >
</button> </button>
</form>
</>
)} )}
</form>
</div> </div>
</div> </div>
</div> </div>
+33 -221
View File
@@ -11,9 +11,9 @@ import type {
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001"; const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
// Context settings // Context settings
const RECENT_MESSAGES_COUNT = 20; // Last N messages for context (increased for better continuity) const RECENT_MESSAGES_COUNT = 15; // Last N messages for context (increased for better scene continuity)
const SUMMARY_THRESHOLD = 20; // After how many messages to generate summary const SUMMARY_THRESHOLD = 20; // After how many messages to generate summary
const WORLD_STATE_UPDATE_INTERVAL = 3; // Update world state every N messages (reduced for better continuity) const WORLD_STATE_UPDATE_INTERVAL = 5; // Update world state every N messages
interface DeepSeekMessage { interface DeepSeekMessage {
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
@@ -37,12 +37,12 @@ interface DeepSeekResponse {
export async function sendMessage( export async function sendMessage(
messages: DeepSeekMessage[], messages: DeepSeekMessage[],
temperature: number = 0.8, temperature: number = 0.8,
max_tokens: number = 2000, max_tokens: number = 1000,
): Promise<string> { ): Promise<string> {
const response = await fetch(`${API_BASE}/api/deepseek/chat`, { const response = await fetch(`${API_BASE}/api/deepseek/chat`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json",
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
@@ -82,13 +82,13 @@ export async function sendMessageStream(
const response = await fetch(`${API_BASE}/api/deepseek/chat/stream`, { const response = await fetch(`${API_BASE}/api/deepseek/chat/stream`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json",
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ body: JSON.stringify({
messages, messages,
temperature, temperature,
max_tokens: 3000, max_tokens: 1000,
}), }),
signal, signal,
}); });
@@ -110,27 +110,19 @@ export async function sendMessageStream(
const reader = response.body?.getReader(); const reader = response.body?.getReader();
if (!reader) throw new Error("No response body"); if (!reader) throw new Error("No response body");
const decoder = new TextDecoder("utf-8"); const decoder = new TextDecoder();
let fullContent = ""; let fullContent = "";
let buffer = ""; // Buffer for incomplete lines
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
// Append new data to buffer const chunk = decoder.decode(value, { stream: true });
buffer += decoder.decode(value, { stream: true }); const lines = chunk.split("\n").filter((line) => line.trim() !== "");
// Process complete lines only
const lines = buffer.split("\n");
// Keep last incomplete line in buffer
buffer = lines.pop() || "";
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); if (line.startsWith("data: ")) {
if (!trimmed || !trimmed.startsWith("data: ")) continue; const data = line.slice(6);
const data = trimmed.slice(6);
if (data === "[DONE]") continue; if (data === "[DONE]") continue;
try { try {
@@ -141,26 +133,10 @@ export async function sendMessageStream(
onChunk(content); onChunk(content);
} }
} catch { } catch {
// Incomplete JSON - will be handled in next chunk // Ignore parse errors
} }
} }
} }
// 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
}
}
} }
return fullContent; return fullContent;
@@ -194,32 +170,9 @@ This is an adult story. Restrictions lifted for:
NSFW RULES: NSFW RULES:
1. Describe scenes in detail, but artistically 1. Describe scenes in detail, but artistically
2. Violence must have consequences and weight 2. Violence must have consequences and weight
3. Do not censor what happens 3. Intimate scenes only on player's initiative
4. Maintain the atmosphere and tone of the story 4. Do not censor what happens
5. 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.
` `
: ""; : "";
@@ -246,8 +199,7 @@ LANGUAGE: ${story.language}
GENRE: ${story.genre.join(", ")} GENRE: ${story.genre.join(", ")}
SETTING: ${settingInfo} SETTING: ${settingInfo}
IMPORTANT: Respond ONLY in language: ${story.language}. IMPORTANT: Respond ONLY in language: ${story.language}. Use proper grammar and spelling.
CRITICAL: Write every word COMPLETELY. No truncated or cut-off words. Perfect grammar and spelling required. Double-check every word for typos.
=== PLAYER CHARACTER === === PLAYER CHARACTER ===
Name: ${player?.name || "Hero"} Name: ${player?.name || "Hero"}
@@ -271,8 +223,6 @@ ${nsfwBlock}
6. Do not assume what player wants to do next 6. Do not assume what player wants to do next
7. No time skips without explicit indication 7. No time skips without explicit indication
8. If action not written by player — it did NOT happen 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 === === PROTAGONIST HANDLING ===
Guidelines for the main character (MC): Guidelines for the main character (MC):
@@ -305,7 +255,6 @@ RIGHT: **"Я ему не доверяю,"** пробормотала она.
Descriptions and narration — plain text without any asterisks. Descriptions and narration — plain text without any asterisks.
${oocRules} ${oocRules}
Respond in language: ${story.language} Respond in language: ${story.language}
CRITICAL: Write every word COMPLETELY. No truncated or cut-off words. Perfect grammar and spelling required!
=== PLAYER CHARACTER === === PLAYER CHARACTER ===
Name: ${player?.name || "Hero"} Name: ${player?.name || "Hero"}
@@ -318,26 +267,8 @@ Description: ${playerDescription}`;
export function buildWorldContext(story: Story): string { export function buildWorldContext(story: Story): string {
const charactersInfo = const charactersInfo =
story.characters.length > 0 story.characters.length > 0
? `CHARACTER PORTRAYAL IS LAW — not suggestion. Every action, reaction, ` + ? story.characters
`word and silence must match the description exactly. ` + .map((c) => `- ${c.name} (${c.role}): ${c.description}`)
`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") .join("\n")
: "Not specified"; : "Not specified";
@@ -348,10 +279,6 @@ Description: ${story.world.description}
World rules: ${story.world.rules.join("; ")} World rules: ${story.world.rules.join("; ")}
=== WORLD CHARACTERS === === 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} ${charactersInfo}
=== MAIN PLOT === === MAIN PLOT ===
@@ -368,7 +295,7 @@ export function buildDynamicContext(
const state = session.currentState; const state = session.currentState;
const summary = session.storySummary || "The story just began."; const summary = session.storySummary || "The story just began.";
const keyEvents = session.keyEvents?.length const keyEvents = session.keyEvents?.length
? session.keyEvents.slice(-7).join("\n- ") ? session.keyEvents.slice(-5).join("\n- ")
: "No significant events yet."; : "No significant events yet.";
// World state for character tracking // World state for character tracking
@@ -385,9 +312,8 @@ export function buildDynamicContext(
• Do NOT ask "What do you do?" — end with atmosphere, not questions • Do NOT ask "What do you do?" — end with atmosphere, not questions
• Format dialogue: **"text"** (double asterisks = bold) • Format dialogue: **"text"** (double asterisks = bold)
• React to player's words explicitly • React to player's words explicitly
CRITICAL: Write every word COMPLETELY. No truncated words. Perfect grammar required! Use proper grammar and spelling in the story language
• Characters can only be where they logically should be based on their last known location • 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 ` return `
@@ -396,10 +322,7 @@ Health: ${state.health}%
Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"} Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
${worldStateContext} ${worldStateContext}
=== STORY SUMMARY (IMPORTANT - THIS IS YOUR MEMORY) === === STORY SUMMARY ===
The following summary contains CRITICAL information about past events, characters, promises, and relationships.
You MUST use this info to maintain story consistency:
${summary} ${summary}
=== KEY EVENTS === === KEY EVENTS ===
@@ -443,19 +366,13 @@ export async function generateStoryResponse(
// Build final system prompt // Build final system prompt
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext; 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[] = [ const messages: DeepSeekMessage[] = [
{ role: "system", content: systemPrompt }, { role: "system", content: systemPrompt },
...recentMessages.map((msg) => ({ ...recentMessages.map((msg) => ({
role: msg.role as "user" | "assistant", role: msg.role as "user" | "assistant",
content: msg.content, content: msg.content,
})), })),
{ role: "user", content: finalUserMessage }, { role: "user", content: userMessage },
]; ];
// Use temperature from story settings (default 0.9 for balanced creative writing) // Use temperature from story settings (default 0.9 for balanced creative writing)
@@ -482,67 +399,13 @@ export async function generateStoryResponseStream(
const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT); const recentMessages = chatHistory.slice(-RECENT_MESSAGES_COUNT);
const systemPrompt = styleRules + "\n" + worldContext + "\n" + dynamicContext; 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[] = [ const messages: DeepSeekMessage[] = [
{ role: "system", content: systemPrompt }, { role: "system", content: systemPrompt },
...truncatedMessages, ...recentMessages.map((msg) => ({
{ role: "user", content: finalUserMessage }, role: msg.role as "user" | "assistant",
content: msg.content,
})),
{ role: "user", content: userMessage },
]; ];
return sendMessageStream(messages, story.temperature || 0.9, onChunk, signal); return sendMessageStream(messages, story.temperature || 0.9, onChunk, signal);
@@ -580,18 +443,6 @@ export async function generateStorySummary(
- What character ALREADY said or did (brief): - What character ALREADY said or did (brief):
- What character KNOWS and DOESN'T KNOW (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 const prompt = previousSummary
? `Update the story summary with new events. ? `Update the story summary with new events.
@@ -600,38 +451,26 @@ ${previousSummary}
NEW EVENTS: NEW EVENTS:
${conversationText} ${conversationText}
${importantReminder}
Write an updated summary in the following format: Write an updated summary in the following format:
=== GENERAL SUMMARY === === GENERAL SUMMARY ===
(5-7 sentences): key events, hero's current location, important decisions, ongoing conflicts. Briefly (3-4 sentences): key events, hero's location, important decisions.
=== IMPORTANT FACTS ===
- List any promises/agreements made
- List any items gained/lost
- List current goals/quests
=== CHARACTER CARDS === === CHARACTER CARDS ===
For EACH character that appeared in the story, fill out a card: For EACH character that appeared in the story, fill out a card:
${characterTemplate} ${characterTemplate}
NEVER remove character info from previous summary. Only add new info or update existing. Update character info based on new events. Maintain consistency.
Write the summary in language: ${story.language}` Write the summary in language: ${story.language}`
: `Create a summary of this story's events: : `Create a summary of this story's events:
${conversationText} ${conversationText}
${importantReminder}
Write the summary in the following format: Write the summary in the following format:
=== GENERAL SUMMARY === === GENERAL SUMMARY ===
(5-7 sentences): what happened, hero's location, important decisions, ongoing conflicts. Briefly (3-4 sentences): what happened, hero's location, important decisions.
=== IMPORTANT FACTS ===
- List any promises/agreements made
- List any items gained/lost
- List current goals/quests
=== CHARACTER CARDS === === CHARACTER CARDS ===
For EACH character that appeared in the story, fill out a card: For EACH character that appeared in the story, fill out a card:
@@ -643,22 +482,12 @@ Write the summary in language: ${story.language}`;
const summaryMessages: DeepSeekMessage[] = [ const summaryMessages: DeepSeekMessage[] = [
{ {
role: "system", role: "system",
content: `You are a story summary assistant. Your summaries are CRITICAL for story continuity - the main AI RELIES on them to remember what happened. 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}`,
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 }, { role: "user", content: prompt },
]; ];
return sendMessage(summaryMessages, 0.1); // Low temp for factual, concise summary return sendMessage(summaryMessages, 0.3);
} }
/** /**
@@ -877,20 +706,3 @@ Situation: ${currentScene.situation}`;
return result; 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.]`;
}
+4 -127
View File
@@ -55,36 +55,11 @@ async function translateToEnglish(text: string): Promise<string> {
} }
/** /**
* Extracts UUID from GeminiGen image URL * Generates an image from a prompt (portrait format)
* 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 * Returns image URL
*/ */
async function generateImageFromPrompt( async function generateImageFromPrompt(prompt: string): Promise<string> {
prompt: string, console.log("Generating image with prompt:", prompt);
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`, { const response = await fetch(`${API_BASE}/api/generate-image`, {
method: "POST", method: "POST",
@@ -92,7 +67,7 @@ async function generateImageFromPrompt(
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include", credentials: "include",
body: JSON.stringify({ prompt, orientation, refHistory }), body: JSON.stringify({ prompt }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -157,104 +132,6 @@ export async function generateAvatarUrl(
return generateImageFromPrompt(prompt); 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 * Poll for image generation result
*/ */
-1
View File
@@ -10,7 +10,6 @@ export interface Character {
age?: CharacterAge; // возраст персонажа age?: CharacterAge; // возраст персонажа
gender?: CharacterGender; // пол персонажа gender?: CharacterGender; // пол персонажа
avatarUrl?: string; // URL аватара персонажа avatarUrl?: string; // URL аватара персонажа
saveAsGlobal?: boolean; // сохранить как глобального NPC
} }
// NPC персонаж (сохранённый в БД) // NPC персонаж (сохранённый в БД)