Compare commits
4 Commits
main
..
96432d22f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 96432d22f5 | |||
| a4cbc2db86 | |||
| 49f2b9261d | |||
| 56e78053cf |
+27
-56
@@ -198,16 +198,18 @@ function validateDeepSeekRequest(body) {
|
||||
const errors = [];
|
||||
const { messages, temperature, max_tokens } = body;
|
||||
|
||||
// Validate messages - only check critical errors, truncation handled in sanitize
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
errors.push("messages must be an array");
|
||||
} else {
|
||||
if (messages.length === 0) {
|
||||
errors.push("messages cannot be empty");
|
||||
}
|
||||
// Don't error on too many messages - we'll truncate them
|
||||
// Don't error on too long messages - we'll truncate them
|
||||
if (messages.length > DEEPSEEK_LIMITS.MAX_MESSAGES) {
|
||||
errors.push(`too many messages (max ${DEEPSEEK_LIMITS.MAX_MESSAGES})`);
|
||||
}
|
||||
|
||||
let totalLength = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (!msg || typeof msg !== "object") {
|
||||
@@ -221,7 +223,20 @@ 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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,37 +264,8 @@ 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) {
|
||||
// First truncate to fit limits
|
||||
const truncatedMessages = truncateMessagesToFit(messages);
|
||||
|
||||
return truncatedMessages.map((msg) => ({
|
||||
return messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: String(msg.content),
|
||||
}));
|
||||
@@ -1073,7 +1059,7 @@ app.get("/api/admin/stats", requireAuth, async (req, res) => {
|
||||
// Прокси для генерации изображений через Grok (обход CORS)
|
||||
app.post("/api/generate-image", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { prompt, orientation = "portrait", refHistory } = req.body;
|
||||
const { prompt } = req.body;
|
||||
const apiKey = process.env.GEMINIGEN_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -1082,27 +1068,14 @@ app.post("/api/generate-image", requireAuth, async (req, res) => {
|
||||
.json({ error: "GeminiGen API key not configured" });
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Generating image with Grok, prompt:",
|
||||
prompt,
|
||||
"orientation:",
|
||||
orientation,
|
||||
"refHistory:",
|
||||
refHistory,
|
||||
);
|
||||
console.log("Generating image with Grok, prompt:", prompt);
|
||||
|
||||
// Используем FormData для multipart/form-data
|
||||
const formData = new FormData();
|
||||
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");
|
||||
|
||||
// Добавляем референс истории если есть (для консистентности персонажей)
|
||||
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",
|
||||
{
|
||||
@@ -1244,7 +1217,7 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
|
||||
const response = await fetch(DEEPSEEK_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -1252,7 +1225,6 @@ app.post("/api/deepseek/chat", requireAuth, async (req, res) => {
|
||||
messages: sanitizedMessages,
|
||||
temperature,
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -1314,7 +1286,6 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
|
||||
messages: sanitizedMessages,
|
||||
temperature,
|
||||
max_tokens: clampedMaxTokens,
|
||||
top_p: 0.95,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
@@ -1326,13 +1297,13 @@ app.post("/api/deepseek/chat/stream", requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// 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("Connection", "keep-alive");
|
||||
|
||||
// Pipe the stream
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const pump = async () => {
|
||||
try {
|
||||
@@ -1395,7 +1366,7 @@ app.post("/api/deepseek/translate", requireAuth, async (req, res) => {
|
||||
const response = await fetch(DEEPSEEK_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,4 +8,3 @@ export {
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { SessionSelector } from "./SessionSelector";
|
||||
export { CharacterPanel } from "./CharacterPanel";
|
||||
export { default as StreamingText } from "./StreamingText";
|
||||
|
||||
@@ -714,27 +714,6 @@
|
||||
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;
|
||||
@@ -1134,120 +1113,3 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getNPCCharacters,
|
||||
createNPCCharacter,
|
||||
} from "../services/api";
|
||||
import { generateAvatarUrl, generateBannerUrl } from "../services/imageGen";
|
||||
import { generateAvatarUrl } from "../services/imageGen";
|
||||
import { useStoryGeneration } from "../hooks/useStoryGeneration";
|
||||
import type {
|
||||
Character,
|
||||
@@ -92,8 +92,6 @@ 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);
|
||||
|
||||
@@ -156,7 +154,6 @@ export default function CreateStoryPage() {
|
||||
const story = await getStory(id);
|
||||
|
||||
if (story) {
|
||||
setCoverImage(story.coverImage || "");
|
||||
setForm({
|
||||
title: story.title,
|
||||
description: story.description || "",
|
||||
@@ -277,7 +274,7 @@ export default function CreateStoryPage() {
|
||||
const handleCharacterChange = (
|
||||
index: number,
|
||||
field: keyof Character,
|
||||
value: string | boolean,
|
||||
value: string,
|
||||
) => {
|
||||
const newCharacters = [...form.characters];
|
||||
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 newRules = [...form.worldRules];
|
||||
@@ -464,7 +430,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] ||
|
||||
"Русский",
|
||||
@@ -488,10 +454,7 @@ export default function CreateStoryPage() {
|
||||
const saveCharactersAsGlobalNPCs = async () => {
|
||||
const existingNames = savedNPCs.map((npc) => npc.name.toLowerCase());
|
||||
|
||||
// Only save characters that have saveAsGlobal flag enabled
|
||||
const charsToSave = storyCharacters.filter((c) => c.saveAsGlobal);
|
||||
|
||||
for (const char of charsToSave) {
|
||||
for (const char of storyCharacters) {
|
||||
// Skip if NPC with this name already exists
|
||||
if (existingNames.includes(char.name.toLowerCase())) {
|
||||
continue;
|
||||
@@ -656,10 +619,9 @@ 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.0, label: "🎨 Креативный", desc: "1.0" },
|
||||
{ value: 1.05, label: "🎨 Креативный", desc: "1.05" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -838,64 +800,6 @@ 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>
|
||||
@@ -1142,20 +1046,6 @@ 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>
|
||||
|
||||
+29
-305
@@ -14,39 +14,6 @@
|
||||
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;
|
||||
}
|
||||
@@ -276,7 +243,6 @@
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scroll-to-bottom-btn {
|
||||
@@ -312,13 +278,12 @@
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
padding: 1.5rem 2rem;
|
||||
padding-left: max(2rem, env(safe-area-inset-left));
|
||||
padding-right: max(2rem, env(safe-area-inset-right));
|
||||
padding: 1rem;
|
||||
padding-left: max(1rem, env(safe-area-inset-left));
|
||||
padding-right: max(1rem, 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;
|
||||
@@ -347,11 +312,10 @@
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 750px;
|
||||
max-width: 88%;
|
||||
animation: fadeIn 0.25s ease;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -366,49 +330,31 @@
|
||||
}
|
||||
|
||||
.message.user {
|
||||
/* No align-self - inherit center from parent */
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
/* No align-self - inherit center from parent */
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: 0.5rem 0;
|
||||
line-height: 1.8;
|
||||
font-size: 1.05rem;
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 18px;
|
||||
line-height: 1.7;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.message-content {
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
text-align: left;
|
||||
font-size: 22px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.messages-container > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.message.user {
|
||||
/* Modern minimal card style */
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
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;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
@@ -418,18 +364,12 @@
|
||||
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.25rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Markdown стили */
|
||||
@@ -478,10 +418,9 @@
|
||||
.message-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0;
|
||||
gap: 1rem;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
@@ -490,13 +429,11 @@
|
||||
}
|
||||
|
||||
.message.user .message-time {
|
||||
color: #6b7280;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.message.user .message-footer {
|
||||
flex-direction: row;
|
||||
padding-left: 1.75rem;
|
||||
margin-top: 0.35rem;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
@@ -702,122 +639,19 @@
|
||||
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 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));
|
||||
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 {
|
||||
@@ -940,64 +774,15 @@
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Streaming message animation - simple fade effect */
|
||||
.message.streaming .message-content {
|
||||
/* Streaming message animation */
|
||||
.message.streaming .message-text {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.message.streaming .message-text::after {
|
||||
content: "▋";
|
||||
animation: blink 1s infinite;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Game Layout with Character Panel */
|
||||
@@ -1227,64 +1012,3 @@
|
||||
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
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -19,7 +18,6 @@ import type {
|
||||
ChatMessage,
|
||||
MessageVersion,
|
||||
PlayerCharacter,
|
||||
WorldState,
|
||||
} from "../types";
|
||||
import {
|
||||
useGameSession,
|
||||
@@ -27,11 +25,7 @@ import {
|
||||
useCharacterDetection,
|
||||
useLazyMessages,
|
||||
} from "../hooks";
|
||||
import {
|
||||
SessionSelector,
|
||||
CharacterPanel,
|
||||
StreamingText,
|
||||
} from "../components/game";
|
||||
import { SessionSelector, CharacterPanel } from "../components/game";
|
||||
import "./GamePage.css";
|
||||
|
||||
function generateId(): string {
|
||||
@@ -82,7 +76,7 @@ export default function GamePage() {
|
||||
resetStreaming,
|
||||
startStreaming,
|
||||
abortController,
|
||||
// abort - не используется, т.к. кнопка "Остановить" закомментирована
|
||||
abort,
|
||||
getLatestContent,
|
||||
} = useStreamingResponse();
|
||||
|
||||
@@ -114,7 +108,6 @@ 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);
|
||||
@@ -336,7 +329,7 @@ export default function GamePage() {
|
||||
const response = await generateStoryResponseStream(
|
||||
story,
|
||||
session.messages,
|
||||
userMessage.content, // Use userMessage.content to include [OOC: ...] wrapper
|
||||
input.trim(),
|
||||
updateStreamingContent,
|
||||
playerCharacter || undefined,
|
||||
session,
|
||||
@@ -354,33 +347,13 @@ export default function GamePage() {
|
||||
|
||||
const allMessages = [...updatedMessages, assistantMessage];
|
||||
|
||||
// Skip state updates for OOC messages - they are meta-conversations
|
||||
let newKeyEvents = session.keyEvents || [];
|
||||
let newSummary = session.storySummary;
|
||||
let newWorldState = session.worldState;
|
||||
|
||||
if (!isOocMode) {
|
||||
newKeyEvents = await extractKeyEvents(
|
||||
const 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`,
|
||||
);
|
||||
let newSummary = session.storySummary;
|
||||
if (allMessages.length % 15 === 0 && allMessages.length > 0) {
|
||||
newSummary = await generateStorySummary(
|
||||
story,
|
||||
allMessages,
|
||||
@@ -389,6 +362,7 @@ export default function GamePage() {
|
||||
}
|
||||
|
||||
// Update world state for better character tracking
|
||||
let newWorldState = session.worldState;
|
||||
if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
|
||||
try {
|
||||
newWorldState = await updateWorldState(
|
||||
@@ -400,7 +374,6 @@ export default function GamePage() {
|
||||
console.warn("Failed to update world state:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalSession: GameSession = {
|
||||
...session,
|
||||
@@ -444,243 +417,8 @@ export default function GamePage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Закомментирована кнопка остановки - функция сохранена на будущее
|
||||
// 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 handleStop = () => {
|
||||
abort();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -787,25 +525,6 @@ 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,
|
||||
@@ -813,7 +532,7 @@ export default function GamePage() {
|
||||
editContent.trim(),
|
||||
updateStreamingContent,
|
||||
playerCharacter || undefined,
|
||||
sessionForEdit,
|
||||
session,
|
||||
signal,
|
||||
);
|
||||
flushStreamingContent();
|
||||
@@ -843,14 +562,14 @@ export default function GamePage() {
|
||||
assistantMessage,
|
||||
];
|
||||
|
||||
// Update world state after edit regeneration (based on recalculated state)
|
||||
let newWorldState = recalculatedWorldState;
|
||||
if (shouldUpdateWorldState(allMessages.length, recalculatedWorldState)) {
|
||||
// Update world state after edit regeneration
|
||||
let newWorldState = session.worldState;
|
||||
if (shouldUpdateWorldState(allMessages.length, session.worldState)) {
|
||||
try {
|
||||
newWorldState = await updateWorldState(
|
||||
story,
|
||||
allMessages,
|
||||
recalculatedWorldState,
|
||||
session.worldState,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("Failed to update world state:", e);
|
||||
@@ -1070,23 +789,8 @@ export default function GamePage() {
|
||||
↑ Загрузить ещё {hiddenCount} сообщений
|
||||
</button>
|
||||
)}
|
||||
{/* 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;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`message ${message.role}${isLastAssistant ? " last-assistant" : ""}${isContinuingThis ? " continuing" : ""}`}
|
||||
>
|
||||
{visibleMessages.map((message) => (
|
||||
<div key={message.id} className={`message ${message.role}`}>
|
||||
{editingMessageId === message.id ? (
|
||||
<div className="message-edit-form">
|
||||
<textarea
|
||||
@@ -1113,20 +817,7 @@ export default function GamePage() {
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
@@ -1140,8 +831,7 @@ export default function GamePage() {
|
||||
</span>
|
||||
{message.role === "user" && !isLoading && (
|
||||
<div className="message-actions">
|
||||
{message.versions &&
|
||||
message.versions.length > 1 && (
|
||||
{message.versions && message.versions.length > 1 && (
|
||||
<div className="version-switcher">
|
||||
<button
|
||||
className="version-btn"
|
||||
@@ -1180,39 +870,26 @@ export default function GamePage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
|
||||
{isLoading && streamingContent && !isContinuing && (
|
||||
{isLoading && streamingContent && (
|
||||
<div className="message assistant streaming">
|
||||
<div className="message-content">
|
||||
<StreamingText
|
||||
content={streamingContent}
|
||||
isStreaming={true}
|
||||
/>
|
||||
<ReactMarkdown>{streamingContent}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !streamingContent && !isContinuing && (
|
||||
<div className="message assistant streaming">
|
||||
<div className="message-content">
|
||||
<span className="loading-text">Генерация...</span>
|
||||
{isLoading && !streamingContent && (
|
||||
<div className="message assistant loading">
|
||||
<div className="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></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>
|
||||
@@ -1232,39 +909,6 @@ export default function GamePage() {
|
||||
</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
|
||||
className={`input-container${isOocMode ? " ooc-mode" : ""}`}
|
||||
onSubmit={(e) => {
|
||||
@@ -1297,7 +941,7 @@ export default function GamePage() {
|
||||
? "[OOC] Напиши инструкцию для ИИ..."
|
||||
: "Что ты хочешь сделать?..."
|
||||
}
|
||||
disabled={false}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
name="chat-input"
|
||||
autoComplete="off"
|
||||
@@ -1309,6 +953,15 @@ export default function GamePage() {
|
||||
data-lpignore="true"
|
||||
data-gramm="false"
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="send-btn stop-btn"
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
@@ -1316,9 +969,8 @@ export default function GamePage() {
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+33
-221
@@ -11,9 +11,9 @@ import type {
|
||||
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
|
||||
|
||||
// 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 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 {
|
||||
role: "system" | "user" | "assistant";
|
||||
@@ -37,12 +37,12 @@ interface DeepSeekResponse {
|
||||
export async function sendMessage(
|
||||
messages: DeepSeekMessage[],
|
||||
temperature: number = 0.8,
|
||||
max_tokens: number = 2000,
|
||||
max_tokens: number = 1000,
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${API_BASE}/api/deepseek/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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; charset=utf-8",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: 3000,
|
||||
max_tokens: 1000,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
@@ -110,27 +110,19 @@ export async function sendMessageStream(
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
const decoder = new TextDecoder();
|
||||
let fullContent = "";
|
||||
let buffer = ""; // Buffer for incomplete lines
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
// 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() || "";
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split("\n").filter((line) => line.trim() !== "");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith("data: ")) continue;
|
||||
|
||||
const data = trimmed.slice(6);
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") continue;
|
||||
|
||||
try {
|
||||
@@ -141,26 +133,10 @@ export async function sendMessageStream(
|
||||
onChunk(content);
|
||||
}
|
||||
} 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;
|
||||
@@ -194,32 +170,9 @@ 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. 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.
|
||||
3. Intimate scenes only on player's initiative
|
||||
4. Do not censor what happens
|
||||
5. Maintain the atmosphere and tone of the story
|
||||
`
|
||||
: "";
|
||||
|
||||
@@ -246,8 +199,7 @@ LANGUAGE: ${story.language}
|
||||
GENRE: ${story.genre.join(", ")}
|
||||
SETTING: ${settingInfo}
|
||||
|
||||
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.
|
||||
IMPORTANT: Respond ONLY in language: ${story.language}. Use proper grammar and spelling.
|
||||
|
||||
=== PLAYER CHARACTER ===
|
||||
Name: ${player?.name || "Hero"}
|
||||
@@ -271,8 +223,6 @@ ${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):
|
||||
@@ -305,7 +255,6 @@ 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"}
|
||||
@@ -318,26 +267,8 @@ Description: ${playerDescription}`;
|
||||
export function buildWorldContext(story: Story): string {
|
||||
const charactersInfo =
|
||||
story.characters.length > 0
|
||||
? `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}`,
|
||||
)
|
||||
? story.characters
|
||||
.map((c) => `- ${c.name} (${c.role}): ${c.description}`)
|
||||
.join("\n")
|
||||
: "Not specified";
|
||||
|
||||
@@ -348,10 +279,6 @@ 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 ===
|
||||
@@ -368,7 +295,7 @@ export function buildDynamicContext(
|
||||
const state = session.currentState;
|
||||
const summary = session.storySummary || "The story just began.";
|
||||
const keyEvents = session.keyEvents?.length
|
||||
? session.keyEvents.slice(-7).join("\n- ")
|
||||
? session.keyEvents.slice(-5).join("\n- ")
|
||||
: "No significant events yet.";
|
||||
|
||||
// World state for character tracking
|
||||
@@ -385,9 +312,8 @@ 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
|
||||
• 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`
|
||||
• Use proper grammar and spelling in the story language
|
||||
• Characters can only be where they logically should be based on their last known location`
|
||||
: "";
|
||||
|
||||
return `
|
||||
@@ -396,10 +322,7 @@ Health: ${state.health}%
|
||||
Inventory: ${state.inventory.length > 0 ? state.inventory.join(", ") : "Empty"}
|
||||
${worldStateContext}
|
||||
|
||||
=== 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:
|
||||
|
||||
=== STORY SUMMARY ===
|
||||
${summary}
|
||||
|
||||
=== KEY EVENTS ===
|
||||
@@ -443,19 +366,13 @@ 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: finalUserMessage },
|
||||
{ role: "user", content: userMessage },
|
||||
];
|
||||
|
||||
// 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 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 },
|
||||
...truncatedMessages,
|
||||
{ role: "user", content: finalUserMessage },
|
||||
...recentMessages.map((msg) => ({
|
||||
role: msg.role as "user" | "assistant",
|
||||
content: msg.content,
|
||||
})),
|
||||
{ role: "user", content: userMessage },
|
||||
];
|
||||
|
||||
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 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.
|
||||
|
||||
@@ -600,38 +451,26 @@ ${previousSummary}
|
||||
|
||||
NEW EVENTS:
|
||||
${conversationText}
|
||||
${importantReminder}
|
||||
|
||||
Write an updated summary in the following format:
|
||||
|
||||
=== GENERAL SUMMARY ===
|
||||
(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
|
||||
Briefly (3-4 sentences): key events, hero's location, important decisions.
|
||||
|
||||
=== CHARACTER CARDS ===
|
||||
For EACH character that appeared in the story, fill out a card:
|
||||
${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}`
|
||||
: `Create a summary of this story's events:
|
||||
|
||||
${conversationText}
|
||||
${importantReminder}
|
||||
|
||||
Write the summary in the following format:
|
||||
|
||||
=== GENERAL SUMMARY ===
|
||||
(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
|
||||
Briefly (3-4 sentences): what happened, hero's location, important decisions.
|
||||
|
||||
=== CHARACTER CARDS ===
|
||||
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[] = [
|
||||
{
|
||||
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.
|
||||
|
||||
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}`,
|
||||
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}`,
|
||||
},
|
||||
{ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -55,36 +55,11 @@ async function translateToEnglish(text: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* Generates an image from a prompt (portrait format)
|
||||
* Returns image URL
|
||||
*/
|
||||
async function generateImageFromPrompt(
|
||||
prompt: string,
|
||||
orientation: "portrait" | "landscape" = "portrait",
|
||||
refHistory?: string,
|
||||
): Promise<string> {
|
||||
console.log(
|
||||
"Generating image with prompt:",
|
||||
prompt,
|
||||
"orientation:",
|
||||
orientation,
|
||||
"refHistory:",
|
||||
refHistory,
|
||||
);
|
||||
async function generateImageFromPrompt(prompt: string): Promise<string> {
|
||||
console.log("Generating image with prompt:", prompt);
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/generate-image`, {
|
||||
method: "POST",
|
||||
@@ -92,7 +67,7 @@ async function generateImageFromPrompt(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ prompt, orientation, refHistory }),
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -157,104 +132,6 @@ 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
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface Character {
|
||||
age?: CharacterAge; // возраст персонажа
|
||||
gender?: CharacterGender; // пол персонажа
|
||||
avatarUrl?: string; // URL аватара персонажа
|
||||
saveAsGlobal?: boolean; // сохранить как глобального NPC
|
||||
}
|
||||
|
||||
// NPC персонаж (сохранённый в БД)
|
||||
|
||||
Reference in New Issue
Block a user