feat: Show character avatar in chat - split view on desktop, background on mobile

This commit is contained in:
Alexej Wolff
2026-05-05 00:48:39 +02:00
parent dad5aa47cb
commit bbefa114f8
5 changed files with 399 additions and 165 deletions
+122
View File
@@ -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
View File
@@ -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>
); );
+1 -1
View File
@@ -459,7 +459,7 @@
.npc-grid { .npc-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.npc-card { .npc-card {
height: 320px; height: 320px;
} }
+4 -1
View File
@@ -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>
+8 -1
View File
@@ -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()) {