MVP - Phase One Complete

This commit is contained in:
Aodhan Collins
2025-10-11 22:48:35 +01:00
parent eccd456c59
commit a1c8ae5f5b
7 changed files with 1221 additions and 46 deletions

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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>
</>
) : (