MVP - Phase One Complete
This commit is contained in:
@@ -130,6 +130,37 @@ body {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: 2px solid #667eea;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.response-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.model-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -688,6 +719,159 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Public/Private Message Sections */
|
||||
.public-messages-section {
|
||||
background: #f0f4ff;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.public-messages-section h3 {
|
||||
color: #667eea;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.public-messages {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.public-message {
|
||||
background: white;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.private-messages-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.private-messages-section h3 {
|
||||
color: #2d3748;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Message Composer */
|
||||
.message-composer {
|
||||
background: #f7fafc;
|
||||
padding: 1rem;
|
||||
border-top: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.visibility-selector {
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.visibility-selector label {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.visibility-selector select {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mixed-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mixed-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mixed-inputs textarea {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.mixed-inputs textarea:first-child {
|
||||
border-left: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.mixed-inputs textarea:last-child {
|
||||
border-left: 3px solid #e53e3e;
|
||||
}
|
||||
|
||||
.mixed-inputs textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
/* Storyteller Public Feed */
|
||||
.public-feed {
|
||||
margin-top: 1rem;
|
||||
background: #f0f4ff;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.public-feed h4 {
|
||||
color: #667eea;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.public-messages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.public-message-item {
|
||||
background: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-left: 3px solid #667eea;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.public-msg-content {
|
||||
flex: 1;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.public-msg-time {
|
||||
color: #a0aec0;
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.storyteller-content {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -5,7 +5,11 @@ const WS_URL = 'ws://localhost:8000';
|
||||
|
||||
function CharacterView({ sessionId, characterId }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [publicMessages, setPublicMessages] = useState([]);
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const [messageVisibility, setMessageVisibility] = useState('private');
|
||||
const [publicPart, setPublicPart] = useState('');
|
||||
const [privatePart, setPrivatePart] = useState('');
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [characterInfo, setCharacterInfo] = useState(null);
|
||||
const [currentScene, setCurrentScene] = useState('');
|
||||
@@ -35,10 +39,13 @@ function CharacterView({ sessionId, characterId }) {
|
||||
|
||||
if (data.type === 'history') {
|
||||
setMessages(data.messages || []);
|
||||
setPublicMessages(data.public_messages || []);
|
||||
} else if (data.type === 'storyteller_response') {
|
||||
setMessages(prev => [...prev, data.message]);
|
||||
} else if (data.type === 'scene_narration') {
|
||||
setCurrentScene(data.content);
|
||||
} else if (data.type === 'public_message') {
|
||||
setPublicMessages(prev => [...prev, data.message]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,16 +67,37 @@ function CharacterView({ sessionId, characterId }) {
|
||||
|
||||
const sendMessage = (e) => {
|
||||
e.preventDefault();
|
||||
if (!inputMessage.trim() || !isConnected) return;
|
||||
if (!isConnected) return;
|
||||
|
||||
const message = {
|
||||
let messageData = {
|
||||
type: 'message',
|
||||
content: inputMessage
|
||||
visibility: messageVisibility
|
||||
};
|
||||
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
setMessages(prev => [...prev, { sender: 'character', content: inputMessage, timestamp: new Date().toISOString() }]);
|
||||
if (messageVisibility === 'mixed') {
|
||||
if (!publicPart.trim() && !privatePart.trim()) return;
|
||||
messageData.content = `PUBLIC: ${publicPart} | PRIVATE: ${privatePart}`;
|
||||
messageData.public_content = publicPart;
|
||||
messageData.private_content = privatePart;
|
||||
} else {
|
||||
if (!inputMessage.trim()) return;
|
||||
messageData.content = inputMessage;
|
||||
}
|
||||
|
||||
wsRef.current.send(JSON.stringify(messageData));
|
||||
|
||||
if (messageVisibility === 'private') {
|
||||
setMessages(prev => [...prev, { sender: 'character', content: inputMessage, visibility: 'private', timestamp: new Date().toISOString() }]);
|
||||
} else if (messageVisibility === 'public') {
|
||||
setPublicMessages(prev => [...prev, { sender: 'character', content: inputMessage, visibility: 'public', timestamp: new Date().toISOString() }]);
|
||||
} else {
|
||||
setPublicMessages(prev => [...prev, { sender: 'character', content: publicPart, visibility: 'mixed', public_content: publicPart, timestamp: new Date().toISOString() }]);
|
||||
setMessages(prev => [...prev, { sender: 'character', content: `PUBLIC: ${publicPart} | PRIVATE: ${privatePart}`, visibility: 'mixed', timestamp: new Date().toISOString() }]);
|
||||
}
|
||||
|
||||
setInputMessage('');
|
||||
setPublicPart('');
|
||||
setPrivatePart('');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -97,41 +125,98 @@ function CharacterView({ sessionId, characterId }) {
|
||||
)}
|
||||
|
||||
<div className="conversation-container">
|
||||
<div className="messages">
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No messages yet. Send a message to the storyteller to begin!</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, index) => (
|
||||
<div key={index} className={`message ${msg.sender === 'character' ? 'sent' : 'received'}`}>
|
||||
<div className="message-header">
|
||||
<span className="message-sender">
|
||||
{msg.sender === 'character' ? characterInfo?.name : '🎲 Storyteller'}
|
||||
</span>
|
||||
<span className="message-time">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
{publicMessages.length > 0 && (
|
||||
<div className="public-messages-section">
|
||||
<h3>📢 Public Actions (All Players See)</h3>
|
||||
<div className="public-messages">
|
||||
{publicMessages.map((msg, index) => (
|
||||
<div key={index} className="public-message">
|
||||
<div className="message-header">
|
||||
<span className="message-sender">{msg.sender === 'character' ? '🎭 Public Action' : '🎲 Scene'}</span>
|
||||
<span className="message-time">{new Date(msg.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<div className="message-content">
|
||||
{msg.visibility === 'mixed' && msg.public_content ? msg.public_content : msg.content}
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-content">{msg.content}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="private-messages-section">
|
||||
<h3>🔒 Private Conversation with Storyteller</h3>
|
||||
<div className="messages">
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No private messages yet. Send a message to the storyteller to begin!</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
) : (
|
||||
messages.map((msg, index) => (
|
||||
<div key={index} className={`message ${msg.sender === 'character' ? 'sent' : 'received'}`}>
|
||||
<div className="message-header">
|
||||
<span className="message-sender">
|
||||
{msg.sender === 'character' ? characterInfo?.name : '🎲 Storyteller'}
|
||||
</span>
|
||||
<span className="message-time">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="message-content">{msg.content}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={sendMessage} className="message-form">
|
||||
<input
|
||||
type="text"
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
placeholder="Send a private message to the storyteller..."
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<button type="submit" disabled={!isConnected}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
<div className="message-composer">
|
||||
<div className="visibility-selector">
|
||||
<label>Message Type:</label>
|
||||
<select value={messageVisibility} onChange={(e) => setMessageVisibility(e.target.value)}>
|
||||
<option value="private">🔒 Private (Only Storyteller Sees)</option>
|
||||
<option value="public">📢 Public (All Players See)</option>
|
||||
<option value="mixed">🔀 Mixed (Public + Private)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{messageVisibility === 'mixed' ? (
|
||||
<form onSubmit={sendMessage} className="message-form mixed-form">
|
||||
<div className="mixed-inputs">
|
||||
<textarea
|
||||
value={publicPart}
|
||||
onChange={(e) => setPublicPart(e.target.value)}
|
||||
placeholder="Public action (all players see)..."
|
||||
disabled={!isConnected}
|
||||
rows="2"
|
||||
/>
|
||||
<textarea
|
||||
value={privatePart}
|
||||
onChange={(e) => setPrivatePart(e.target.value)}
|
||||
placeholder="Private action (only storyteller sees)..."
|
||||
disabled={!isConnected}
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={!isConnected}>
|
||||
Send Mixed Message
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={sendMessage} className="message-form">
|
||||
<input
|
||||
type="text"
|
||||
value={inputMessage}
|
||||
onChange={(e) => setInputMessage(e.target.value)}
|
||||
placeholder={messageVisibility === 'public' ? 'Public action (all players see)...' : 'Private message (only storyteller sees)...'}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<button type="submit" disabled={!isConnected}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,11 +5,13 @@ const WS_URL = 'ws://localhost:8000';
|
||||
|
||||
function StorytellerView({ sessionId }) {
|
||||
const [characters, setCharacters] = useState({});
|
||||
const [publicMessages, setPublicMessages] = useState([]);
|
||||
const [selectedCharacter, setSelectedCharacter] = useState(null);
|
||||
const [responseText, setResponseText] = useState('');
|
||||
const [sceneText, setSceneText] = useState('');
|
||||
const [currentScene, setCurrentScene] = useState('');
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
|
||||
const wsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -27,6 +29,7 @@ function StorytellerView({ sessionId }) {
|
||||
if (data.type === 'session_state') {
|
||||
setCharacters(data.characters || {});
|
||||
setCurrentScene(data.current_scene || '');
|
||||
setPublicMessages(data.public_messages || []);
|
||||
} else if (data.type === 'character_message') {
|
||||
// Update character with new message
|
||||
setCharacters(prev => ({
|
||||
@@ -110,6 +113,30 @@ function StorytellerView({ sessionId }) {
|
||||
setSceneText('');
|
||||
};
|
||||
|
||||
const getSuggestion = async () => {
|
||||
if (!selectedCharacter || isGeneratingSuggestion) return;
|
||||
|
||||
setIsGeneratingSuggestion(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_URL}/sessions/${sessionId}/generate_suggestion?character_id=${selectedCharacter}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate suggestion');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResponseText(data.suggestion);
|
||||
} catch (error) {
|
||||
console.error('Error generating suggestion:', error);
|
||||
alert('Failed to generate AI suggestion. Please try again.');
|
||||
} finally {
|
||||
setIsGeneratingSuggestion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedChar = selectedCharacter ? characters[selectedCharacter] : null;
|
||||
const pendingCount = Object.values(characters).filter(c => c.pending_response).length;
|
||||
|
||||
@@ -150,6 +177,24 @@ function StorytellerView({ sessionId }) {
|
||||
Narrate Scene
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{publicMessages.length > 0 && (
|
||||
<div className="public-feed">
|
||||
<h4>📢 Public Actions Feed ({publicMessages.length})</h4>
|
||||
<div className="public-messages-list">
|
||||
{publicMessages.slice(-5).map((msg, idx) => (
|
||||
<div key={idx} className="public-message-item">
|
||||
<span className="public-msg-content">
|
||||
{msg.visibility === 'mixed' && msg.public_content ? msg.public_content : msg.content}
|
||||
</span>
|
||||
<span className="public-msg-time">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="storyteller-content">
|
||||
@@ -223,9 +268,18 @@ function StorytellerView({ sessionId }) {
|
||||
onChange={(e) => setResponseText(e.target.value)}
|
||||
rows="4"
|
||||
/>
|
||||
<button className="btn-primary" onClick={sendResponse} disabled={!isConnected}>
|
||||
Send Private Response
|
||||
</button>
|
||||
<div className="response-buttons">
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={getSuggestion}
|
||||
disabled={!isConnected || isGeneratingSuggestion}
|
||||
>
|
||||
{isGeneratingSuggestion ? '⏳ Generating...' : '✨ AI Suggest'}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={sendResponse} disabled={!isConnected}>
|
||||
Send Private Response
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user