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 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({
|
||||||
|
|||||||
@@ -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 { 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";
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 персонаж (сохранённый в БД)
|
||||||
|
|||||||
Reference in New Issue
Block a user