feat: Show character avatar in chat - split view on desktop, background on mobile
This commit is contained in:
@@ -237,10 +237,12 @@
|
|||||||
.game-content {
|
.game-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-to-bottom-btn {
|
.scroll-to-bottom-btn {
|
||||||
@@ -272,6 +274,7 @@
|
|||||||
.messages-container {
|
.messages-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
@@ -282,11 +285,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
max-width: 88%;
|
max-width: 88%;
|
||||||
animation: fadeIn 0.25s ease;
|
animation: fadeIn 0.25s ease;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
@@ -714,6 +721,121 @@
|
|||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Game Layout with Character Panel */
|
||||||
|
.game-layout {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-panel {
|
||||||
|
width: 350px;
|
||||||
|
min-width: 350px;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #0a0a0a;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-panel img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-panel-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-panel-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-panel-role {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop layout */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.game-layout.has-character .game-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: character as background */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.character-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout .game-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout.has-character {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout.has-character::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image: var(--character-bg);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: top center;
|
||||||
|
opacity: 0.15;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout.has-character .game-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout.has-character .messages-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout.has-character .message {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout.has-character .input-container {
|
||||||
|
background: rgba(10, 10, 10, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%,
|
0%,
|
||||||
50% {
|
50% {
|
||||||
|
|||||||
+264
-162
@@ -26,6 +26,7 @@ import type {
|
|||||||
ChatMessage,
|
ChatMessage,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
MessageVersion,
|
MessageVersion,
|
||||||
|
Character,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import "./GamePage.css";
|
import "./GamePage.css";
|
||||||
|
|
||||||
@@ -65,6 +66,9 @@ export default function GamePage() {
|
|||||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||||
const [editContent, setEditContent] = useState("");
|
const [editContent, setEditContent] = useState("");
|
||||||
|
const [activeCharacter, setActiveCharacter] = useState<Character | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -83,6 +87,71 @@ export default function GamePage() {
|
|||||||
sessionRef.current = session;
|
sessionRef.current = session;
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
|
// Determine active character from message content
|
||||||
|
const detectActiveCharacter = useCallback(
|
||||||
|
(content: string, characters: Character[]): Character | null => {
|
||||||
|
if (!characters || characters.length === 0) return null;
|
||||||
|
|
||||||
|
const contentLower = content.toLowerCase();
|
||||||
|
|
||||||
|
// Look for character name at the beginning of message or in dialogue
|
||||||
|
for (const char of characters) {
|
||||||
|
if (!char.name) continue;
|
||||||
|
|
||||||
|
// Split name into parts and check each (e.g., "Принцеса Лапис" -> ["принцеса", "лапис"])
|
||||||
|
const nameParts = char.name.toLowerCase().split(/\s+/);
|
||||||
|
|
||||||
|
for (const namePart of nameParts) {
|
||||||
|
if (namePart.length < 3) continue; // Skip short words like "и", "в", etc.
|
||||||
|
|
||||||
|
// Create regex to match whole word only
|
||||||
|
const wordBoundary = new RegExp(
|
||||||
|
`(^|[\\s.,!?;:"'«»—\\-])${namePart}([\\s.,!?;:"'«»—\\-]|$)`,
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if name appears as a whole word in first 200 characters
|
||||||
|
const firstPart = contentLower.substring(0, 200);
|
||||||
|
if (wordBoundary.test(firstPart)) {
|
||||||
|
return char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update active character based on last assistant message
|
||||||
|
useEffect(() => {
|
||||||
|
if (!story?.characters || !session?.messages) return;
|
||||||
|
|
||||||
|
// Find last assistant message
|
||||||
|
const lastAssistantMsg = [...session.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.role === "assistant");
|
||||||
|
|
||||||
|
if (lastAssistantMsg) {
|
||||||
|
const detected = detectActiveCharacter(
|
||||||
|
lastAssistantMsg.content,
|
||||||
|
story.characters,
|
||||||
|
);
|
||||||
|
if (detected && detected.avatarUrl) {
|
||||||
|
setActiveCharacter(detected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [session?.messages, story?.characters, detectActiveCharacter]);
|
||||||
|
|
||||||
|
// Also update during streaming
|
||||||
|
useEffect(() => {
|
||||||
|
if (!story?.characters || !streamingContent) return;
|
||||||
|
|
||||||
|
const detected = detectActiveCharacter(streamingContent, story.characters);
|
||||||
|
if (detected && detected.avatarUrl) {
|
||||||
|
setActiveCharacter(detected);
|
||||||
|
}
|
||||||
|
}, [streamingContent, story?.characters, detectActiveCharacter]);
|
||||||
|
|
||||||
// Warn before leaving if there are unsaved changes
|
// Warn before leaving if there are unsaved changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
@@ -892,127 +961,155 @@ export default function GamePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="game-content">
|
<div
|
||||||
<div
|
className={`game-layout ${activeCharacter?.avatarUrl ? "has-character" : ""}`}
|
||||||
className="messages-container"
|
style={
|
||||||
ref={messagesContainerRef}
|
activeCharacter?.avatarUrl
|
||||||
onScroll={handleScroll}
|
? ({
|
||||||
>
|
"--character-bg": `url(${activeCharacter.avatarUrl})`,
|
||||||
{session?.messages.map((message) => (
|
} as React.CSSProperties)
|
||||||
<div key={message.id} className={`message ${message.role}`}>
|
: undefined
|
||||||
{editingMessageId === message.id ? (
|
}
|
||||||
<div className="message-edit-form">
|
>
|
||||||
<textarea
|
{/* Character panel - desktop only */}
|
||||||
value={editContent}
|
{activeCharacter?.avatarUrl && (
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
<div className="character-panel">
|
||||||
className="message-edit-textarea"
|
<img src={activeCharacter.avatarUrl} alt={activeCharacter.name} />
|
||||||
autoFocus
|
<div className="character-panel-info">
|
||||||
/>
|
<span className="character-panel-name">
|
||||||
<div className="message-edit-actions">
|
{activeCharacter.name}
|
||||||
<button
|
</span>
|
||||||
className="edit-cancel-btn"
|
<span className="character-panel-role">
|
||||||
onClick={handleCancelEdit}
|
{activeCharacter.role}
|
||||||
>
|
</span>
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="edit-save-btn"
|
|
||||||
onClick={() => handleSaveEdit(message.id)}
|
|
||||||
>
|
|
||||||
Сохранить и переиграть
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="message-content">
|
|
||||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
<div className="message-footer">
|
|
||||||
<span className="message-time">
|
|
||||||
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
{message.role === "user" && !isLoading && (
|
|
||||||
<div className="message-actions">
|
|
||||||
{message.versions && message.versions.length > 1 && (
|
|
||||||
<div className="version-switcher">
|
|
||||||
<button
|
|
||||||
className="version-btn"
|
|
||||||
onClick={() =>
|
|
||||||
handleSwitchVersion(message.id, "prev")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<span className="version-indicator">
|
|
||||||
{(message.activeVersion || 0) + 1}/
|
|
||||||
{message.versions.length}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="version-btn"
|
|
||||||
onClick={() =>
|
|
||||||
handleSwitchVersion(message.id, "next")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="edit-btn"
|
|
||||||
onClick={() =>
|
|
||||||
handleEditMessage(message.id, message.content)
|
|
||||||
}
|
|
||||||
title="Редактировать"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
{isLoading && streamingContent && (
|
|
||||||
<div className="message assistant streaming">
|
|
||||||
<div className="message-content">
|
|
||||||
<ReactMarkdown>{streamingContent}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading && !streamingContent && (
|
|
||||||
<div className="message assistant loading">
|
|
||||||
<div className="typing-indicator">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-message">
|
|
||||||
<span>⚠️ {error}</span>
|
|
||||||
<button onClick={() => setError(null)}>✕</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showScrollButton && (
|
|
||||||
<button className="scroll-to-bottom-btn" onClick={scrollToBottom}>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RPG кнопки скрыты — раскомментировать при необходимости
|
<div className="game-content">
|
||||||
|
<div
|
||||||
|
className="messages-container"
|
||||||
|
ref={messagesContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{session?.messages.map((message) => (
|
||||||
|
<div key={message.id} className={`message ${message.role}`}>
|
||||||
|
{editingMessageId === message.id ? (
|
||||||
|
<div className="message-edit-form">
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
className="message-edit-textarea"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="message-edit-actions">
|
||||||
|
<button
|
||||||
|
className="edit-cancel-btn"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="edit-save-btn"
|
||||||
|
onClick={() => handleSaveEdit(message.id)}
|
||||||
|
>
|
||||||
|
Сохранить и переиграть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="message-content">
|
||||||
|
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
<div className="message-footer">
|
||||||
|
<span className="message-time">
|
||||||
|
{new Date(message.timestamp).toLocaleTimeString(
|
||||||
|
"ru-RU",
|
||||||
|
{
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{message.role === "user" && !isLoading && (
|
||||||
|
<div className="message-actions">
|
||||||
|
{message.versions && message.versions.length > 1 && (
|
||||||
|
<div className="version-switcher">
|
||||||
|
<button
|
||||||
|
className="version-btn"
|
||||||
|
onClick={() =>
|
||||||
|
handleSwitchVersion(message.id, "prev")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span className="version-indicator">
|
||||||
|
{(message.activeVersion || 0) + 1}/
|
||||||
|
{message.versions.length}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="version-btn"
|
||||||
|
onClick={() =>
|
||||||
|
handleSwitchVersion(message.id, "next")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="edit-btn"
|
||||||
|
onClick={() =>
|
||||||
|
handleEditMessage(message.id, message.content)
|
||||||
|
}
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isLoading && streamingContent && (
|
||||||
|
<div className="message assistant streaming">
|
||||||
|
<div className="message-content">
|
||||||
|
<ReactMarkdown>{streamingContent}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && !streamingContent && (
|
||||||
|
<div className="message assistant loading">
|
||||||
|
<div className="typing-indicator">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
<span>⚠️ {error}</span>
|
||||||
|
<button onClick={() => setError(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showScrollButton && (
|
||||||
|
<button className="scroll-to-bottom-btn" onClick={scrollToBottom}>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RPG кнопки скрыты — раскомментировать при необходимости
|
||||||
<div className="quick-actions">
|
<div className="quick-actions">
|
||||||
<button onClick={() => handleQuickAction("Осмотреться вокруг")}>
|
<button onClick={() => handleQuickAction("Осмотреться вокруг")}>
|
||||||
👀 Осмотреться
|
👀 Осмотреться
|
||||||
@@ -1029,51 +1126,56 @@ export default function GamePage() {
|
|||||||
</div>
|
</div>
|
||||||
*/}
|
*/}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="input-container"
|
className="input-container"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSend();
|
handleSend();
|
||||||
}}
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
ref={inputRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => {
|
|
||||||
setInput(e.target.value);
|
|
||||||
// Auto-resize
|
|
||||||
e.target.style.height = "auto";
|
|
||||||
e.target.style.height =
|
|
||||||
Math.min(e.target.scrollHeight, 150) + "px";
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
>
|
||||||
placeholder="Что ты хочешь сделать?..."
|
<textarea
|
||||||
disabled={isLoading}
|
ref={inputRef}
|
||||||
rows={1}
|
value={input}
|
||||||
name="chat-input"
|
onChange={(e) => {
|
||||||
autoComplete="off"
|
setInput(e.target.value);
|
||||||
autoCorrect="on"
|
// Auto-resize
|
||||||
autoCapitalize="sentences"
|
e.target.style.height = "auto";
|
||||||
spellCheck={true}
|
e.target.style.height =
|
||||||
enterKeyHint="send"
|
Math.min(e.target.scrollHeight, 150) + "px";
|
||||||
data-form-type="other"
|
}}
|
||||||
data-lpignore="true"
|
onKeyDown={handleKeyDown}
|
||||||
data-gramm="false"
|
placeholder="Что ты хочешь сделать?..."
|
||||||
/>
|
disabled={isLoading}
|
||||||
{isLoading ? (
|
rows={1}
|
||||||
<button
|
name="chat-input"
|
||||||
type="button"
|
autoComplete="off"
|
||||||
onClick={handleStop}
|
autoCorrect="on"
|
||||||
className="send-btn stop-btn"
|
autoCapitalize="sentences"
|
||||||
>
|
spellCheck={true}
|
||||||
⏹
|
enterKeyHint="send"
|
||||||
</button>
|
data-form-type="other"
|
||||||
) : (
|
data-lpignore="true"
|
||||||
<button type="submit" disabled={!input.trim()} className="send-btn">
|
data-gramm="false"
|
||||||
➤
|
/>
|
||||||
</button>
|
{isLoading ? (
|
||||||
)}
|
<button
|
||||||
</form>
|
type="button"
|
||||||
|
onClick={handleStop}
|
||||||
|
className="send-btn stop-btn"
|
||||||
|
>
|
||||||
|
⏹
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim()}
|
||||||
|
className="send-btn"
|
||||||
|
>
|
||||||
|
➤
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -280,7 +280,10 @@ export default function NPCPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn-generate-avatar"
|
className="btn-generate-avatar"
|
||||||
onClick={handleGenerateAvatar}
|
onClick={handleGenerateAvatar}
|
||||||
disabled={generatingAvatar || (!customPrompt.trim() && !description.trim())}
|
disabled={
|
||||||
|
generatingAvatar ||
|
||||||
|
(!customPrompt.trim() && !description.trim())
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{generatingAvatar ? "Генерация..." : "🎨 Сгенерировать"}
|
{generatingAvatar ? "Генерация..." : "🎨 Сгенерировать"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -123,7 +123,14 @@ async function generateImageFromPrompt(prompt: string): Promise<string> {
|
|||||||
export async function generateAvatarUrl(
|
export async function generateAvatarUrl(
|
||||||
options: GenerateAvatarOptions,
|
options: GenerateAvatarOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { description, name, age = "adult", gender = "female", customPrompt, isNsfw } = options;
|
const {
|
||||||
|
description,
|
||||||
|
name,
|
||||||
|
age = "adult",
|
||||||
|
gender = "female",
|
||||||
|
customPrompt,
|
||||||
|
isNsfw,
|
||||||
|
} = options;
|
||||||
|
|
||||||
// If custom prompt provided - use it directly
|
// If custom prompt provided - use it directly
|
||||||
if (customPrompt && customPrompt.trim()) {
|
if (customPrompt && customPrompt.trim()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user