Initial commit
This commit is contained in:
242
frontend/src/components/StorytellerView.js
Normal file
242
frontend/src/components/StorytellerView.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const API_URL = 'http://localhost:8000';
|
||||
const WS_URL = 'ws://localhost:8000';
|
||||
|
||||
function StorytellerView({ sessionId }) {
|
||||
const [characters, setCharacters] = useState({});
|
||||
const [selectedCharacter, setSelectedCharacter] = useState(null);
|
||||
const [responseText, setResponseText] = useState('');
|
||||
const [sceneText, setSceneText] = useState('');
|
||||
const [currentScene, setCurrentScene] = useState('');
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const wsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Connect to WebSocket
|
||||
const ws = new WebSocket(`${WS_URL}/ws/storyteller/${sessionId}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('Storyteller connected to WebSocket');
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'session_state') {
|
||||
setCharacters(data.characters || {});
|
||||
setCurrentScene(data.current_scene || '');
|
||||
} else if (data.type === 'character_message') {
|
||||
// Update character with new message
|
||||
setCharacters(prev => ({
|
||||
...prev,
|
||||
[data.character_id]: {
|
||||
...prev[data.character_id],
|
||||
conversation_history: [
|
||||
...(prev[data.character_id]?.conversation_history || []),
|
||||
data.message
|
||||
],
|
||||
pending_response: true
|
||||
}
|
||||
}));
|
||||
} else if (data.type === 'character_joined') {
|
||||
// Refresh character list
|
||||
fetch(`${API_URL}/sessions/${sessionId}`)
|
||||
.then(res => res.json())
|
||||
.then(session => {
|
||||
const charMap = {};
|
||||
Object.entries(session.characters).forEach(([id, char]) => {
|
||||
charMap[id] = {
|
||||
...char,
|
||||
conversation_history: char.conversation_history || [],
|
||||
pending_response: char.pending_response || false
|
||||
};
|
||||
});
|
||||
setCharacters(charMap);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('Storyteller disconnected from WebSocket');
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
return () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const sendResponse = () => {
|
||||
if (!selectedCharacter || !responseText.trim() || !isConnected) return;
|
||||
|
||||
const message = {
|
||||
type: 'respond_to_character',
|
||||
character_id: selectedCharacter,
|
||||
content: responseText
|
||||
};
|
||||
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
|
||||
// Update local state
|
||||
setCharacters(prev => ({
|
||||
...prev,
|
||||
[selectedCharacter]: {
|
||||
...prev[selectedCharacter],
|
||||
conversation_history: [
|
||||
...(prev[selectedCharacter]?.conversation_history || []),
|
||||
{ sender: 'storyteller', content: responseText, timestamp: new Date().toISOString() }
|
||||
],
|
||||
pending_response: false
|
||||
}
|
||||
}));
|
||||
|
||||
setResponseText('');
|
||||
};
|
||||
|
||||
const narrateScene = () => {
|
||||
if (!sceneText.trim() || !isConnected) return;
|
||||
|
||||
const message = {
|
||||
type: 'narrate_scene',
|
||||
content: sceneText
|
||||
};
|
||||
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
setCurrentScene(sceneText);
|
||||
setSceneText('');
|
||||
};
|
||||
|
||||
const selectedChar = selectedCharacter ? characters[selectedCharacter] : null;
|
||||
const pendingCount = Object.values(characters).filter(c => c.pending_response).length;
|
||||
|
||||
return (
|
||||
<div className="storyteller-view">
|
||||
<div className="storyteller-header">
|
||||
<div>
|
||||
<h1>🎲 Storyteller Dashboard</h1>
|
||||
<p className="session-id">Session ID: <code>{sessionId}</code></p>
|
||||
<p className="connection-status">
|
||||
<span className={`status-indicator ${isConnected ? 'connected' : 'disconnected'}`}>
|
||||
{isConnected ? '● Connected' : '○ Disconnected'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<div className="pending-badge">
|
||||
{pendingCount} pending response{pendingCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="scene-section">
|
||||
<h3>📜 Narrate Scene to All Characters</h3>
|
||||
{currentScene && (
|
||||
<div className="current-scene-display">
|
||||
<strong>Current Scene:</strong> {currentScene}
|
||||
</div>
|
||||
)}
|
||||
<div className="scene-input">
|
||||
<textarea
|
||||
placeholder="Describe the scene that all characters will experience..."
|
||||
value={sceneText}
|
||||
onChange={(e) => setSceneText(e.target.value)}
|
||||
rows="3"
|
||||
/>
|
||||
<button className="btn-primary" onClick={narrateScene} disabled={!isConnected}>
|
||||
Narrate Scene
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="storyteller-content">
|
||||
<div className="character-list">
|
||||
<h3>Characters ({Object.keys(characters).length})</h3>
|
||||
{Object.keys(characters).length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No characters yet. Share the session ID for players to join!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="character-cards">
|
||||
{Object.entries(characters).map(([id, char]) => (
|
||||
<div
|
||||
key={id}
|
||||
className={`character-card ${selectedCharacter === id ? 'selected' : ''} ${char.pending_response ? 'pending' : ''}`}
|
||||
onClick={() => setSelectedCharacter(id)}
|
||||
>
|
||||
<div className="character-card-header">
|
||||
<h4>{char.name}</h4>
|
||||
{char.pending_response && <span className="pending-indicator">●</span>}
|
||||
</div>
|
||||
<p className="character-card-desc">{char.description}</p>
|
||||
{char.personality && <p className="character-card-personality">🎭 {char.personality}</p>}
|
||||
{char.llm_model && <p className="character-card-model">🤖 {char.llm_model}</p>}
|
||||
<p className="character-card-messages">
|
||||
{char.conversation_history?.length || 0} message{char.conversation_history?.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="conversation-panel">
|
||||
{selectedChar ? (
|
||||
<>
|
||||
<div className="panel-header">
|
||||
<h3>Conversation with {selectedChar.name}</h3>
|
||||
{selectedChar.pending_response && (
|
||||
<span className="pending-label">Awaiting Response</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="conversation-messages">
|
||||
{selectedChar.conversation_history?.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No conversation yet with this character.</p>
|
||||
</div>
|
||||
) : (
|
||||
selectedChar.conversation_history?.map((msg, index) => (
|
||||
<div key={index} className={`message ${msg.sender === 'character' ? 'from-character' : 'from-storyteller'}`}>
|
||||
<div className="message-header">
|
||||
<span className="message-sender">
|
||||
{msg.sender === 'character' ? selectedChar.name : 'You (Storyteller)'}
|
||||
</span>
|
||||
<span className="message-time">
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="message-content">{msg.content}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="response-section">
|
||||
<h4>Respond to {selectedChar.name}</h4>
|
||||
<textarea
|
||||
placeholder={`Craft your response to ${selectedChar.name}. This is private and only they will see it.`}
|
||||
value={responseText}
|
||||
onChange={(e) => setResponseText(e.target.value)}
|
||||
rows="4"
|
||||
/>
|
||||
<button className="btn-primary" onClick={sendResponse} disabled={!isConnected}>
|
||||
Send Private Response
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<p>Select a character to view their conversation and respond</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StorytellerView;
|
||||
Reference in New Issue
Block a user