Initial commit

This commit is contained in:
Aodhan Collins
2025-10-11 21:21:36 +01:00
commit eccd456c59
29 changed files with 5375 additions and 0 deletions

39
frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "storyteller-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"socket.io-client": "^4.7.2",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#667eea" />
<meta
name="description"
content="Storyteller RPG - A storyteller-centric roleplaying application"
/>
<title>Storyteller RPG</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,8 @@
{
"short_name": "Storyteller RPG",
"name": "Storyteller RPG Application",
"start_url": ".",
"display": "standalone",
"theme_color": "#667eea",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

703
frontend/src/App.css Normal file
View File

@@ -0,0 +1,703 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
/* Session Setup */
.session-setup {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.setup-container {
background: white;
border-radius: 20px;
padding: 3rem;
max-width: 600px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.setup-container h1 {
font-size: 2.5rem;
color: #2d3748;
margin-bottom: 0.5rem;
text-align: center;
}
.subtitle {
text-align: center;
color: #718096;
margin-bottom: 2rem;
}
.setup-section {
margin-bottom: 2rem;
}
.setup-section h2 {
font-size: 1.5rem;
color: #2d3748;
margin-bottom: 0.5rem;
}
.section-description {
color: #718096;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.input-group input,
.input-group textarea {
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s;
}
.input-group input:focus,
.input-group textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.divider {
text-align: center;
margin: 2rem 0;
position: relative;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #e2e8f0;
}
.divider span {
background: white;
padding: 0 1rem;
position: relative;
color: #a0aec0;
font-weight: 600;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.75rem 2rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.model-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.model-selector label {
font-weight: 600;
color: #2d3748;
font-size: 0.95rem;
}
.model-select {
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
background: white;
cursor: pointer;
transition: all 0.3s;
}
.model-select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.model-hint {
font-size: 0.85rem;
color: #718096;
font-style: italic;
margin: 0;
}
/* Character View */
.character-view {
min-height: 100vh;
background: white;
display: flex;
flex-direction: column;
}
.character-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.character-info h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.character-description {
opacity: 0.9;
margin-bottom: 0.25rem;
}
.character-personality {
opacity: 0.8;
font-style: italic;
}
.connection-status .status-indicator {
background: rgba(255, 255, 255, 0.2);
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.9rem;
}
.status-indicator.connected {
background: rgba(72, 187, 120, 0.3);
}
.current-scene {
background: #f7fafc;
padding: 1.5rem;
margin: 1rem;
border-left: 4px solid #667eea;
border-radius: 8px;
}
.current-scene h3 {
color: #2d3748;
margin-bottom: 0.5rem;
}
.current-scene p {
color: #4a5568;
line-height: 1.6;
}
.conversation-container {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.empty-state {
text-align: center;
color: #a0aec0;
padding: 3rem;
}
.message {
max-width: 70%;
padding: 1rem;
border-radius: 12px;
animation: slideIn 0.3s ease-out;
padding: 0.75rem 1rem;
border-radius: 18px;
line-height: 1.4;
position: relative;
word-wrap: break-word;
}
.message.user {
align-self: flex-end;
background-color: #007bff;
color: white;
border-bottom-right-radius: 4px;
}
.message.other {
align-self: flex-start;
background-color: #f1f1f1;
color: #333;
border-bottom-left-radius: 4px;
}
.message-form {
display: flex;
padding: 1rem;
border-top: 1px solid #eee;
background-color: #f9f9f9;
}
.message-form input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
margin-right: 0.5rem;
}
.message-form button {
background-color: #2ecc71;
color: white;
border: none;
padding: 0 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.message-form button:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.message-form button:hover:not(:disabled) {
background-color: #27ae60;
}
.storyteller-controls {
padding: 1rem;
background-color: #f8f9fa;
border-top: 1px solid #eee;
}
.storyteller-controls textarea {
width: 100%;
padding: 0.75rem;
margin-bottom: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
resize: vertical;
min-height: 60px;
}
.storyteller-controls button {
background-color: #9b59b6;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
width: 100%;
}
/* Storyteller View */
.storyteller-view {
min-height: 100vh;
background: #f7fafc;
}
.storyteller-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.storyteller-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.session-id {
opacity: 0.9;
margin: 0.5rem 0;
}
.session-id code {
background: rgba(255, 255, 255, 0.2);
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.pending-badge {
background: #f56565;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 20px;
font-weight: 600;
animation: pulse 2s infinite;
}
.scene-section {
background: white;
margin: 1rem;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.scene-section h3 {
color: #2d3748;
margin-bottom: 1rem;
}
.current-scene-display {
background: #f7fafc;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
color: #4a5568;
}
.scene-input {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.scene-input textarea {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
}
.scene-input textarea:focus {
outline: none;
border-color: #667eea;
}
.storyteller-content {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
padding: 1rem;
min-height: calc(100vh - 400px);
}
.character-list {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow-y: auto;
}
.character-list h3 {
color: #2d3748;
margin-bottom: 1rem;
}
.character-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.character-card {
padding: 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
}
.character-card:hover {
border-color: #667eea;
transform: translateX(4px);
}
.character-card.selected {
border-color: #667eea;
background: #f0f4ff;
}
.character-card.pending {
border-color: #f56565;
background: #fff5f5;
}
.character-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.character-card-header h4 {
color: #2d3748;
font-size: 1.1rem;
}
.pending-indicator {
color: #f56565;
font-size: 1.5rem;
animation: pulse 2s infinite;
}
.character-card-desc {
color: #718096;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.character-card-personality {
color: #667eea;
font-size: 0.85rem;
font-style: italic;
margin-bottom: 0.25rem;
}
.character-card-model {
color: #48bb78;
font-size: 0.8rem;
font-family: 'Courier New', monospace;
margin-bottom: 0.25rem;
background: #f0fff4;
padding: 0.25rem 0.5rem;
border-radius: 4px;
display: inline-block;
}
.character-card-messages {
color: #a0aec0;
font-size: 0.8rem;
}
.conversation-panel {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
max-height: calc(100vh - 420px);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e2e8f0;
}
.panel-header h3 {
color: #2d3748;
}
.pending-label {
background: #f56565;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.conversation-messages {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.message.from-character {
align-self: flex-start;
background: #f0f4ff;
border: 2px solid #667eea;
max-width: 70%;
padding: 1rem;
border-radius: 12px;
}
.message.from-storyteller {
align-self: flex-end;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
max-width: 70%;
padding: 1rem;
border-radius: 12px;
}
.response-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid #e2e8f0;
}
.response-section h4 {
color: #2d3748;
margin-bottom: 0.75rem;
}
.response-section textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
margin-bottom: 0.75rem;
}
.response-section textarea:focus {
outline: none;
border-color: #667eea;
}
.message.sent {
align-self: flex-end;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.message.received {
align-self: flex-start;
background: #f7fafc;
border: 2px solid #e2e8f0;
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.85rem;
opacity: 0.8;
}
.message-content {
line-height: 1.5;
}
.message-form {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: #f7fafc;
border-top: 2px solid #e2e8f0;
}
.message-form input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
}
.message-form input:focus {
outline: none;
border-color: #667eea;
}
.message-form button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.75rem 2rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.message-form button:hover:not(:disabled) {
transform: translateY(-2px);
}
.message-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@media (max-width: 768px) {
.storyteller-content {
grid-template-columns: 1fr;
}
.character-list {
max-height: 300px;
}
.message {
max-width: 85%;
}
}

39
frontend/src/App.js Normal file
View File

@@ -0,0 +1,39 @@
import React, { useState } from 'react';
import './App.css';
import SessionSetup from './components/SessionSetup';
import StorytellerView from './components/StorytellerView';
import CharacterView from './components/CharacterView';
function App() {
const [sessionId, setSessionId] = useState('');
const [characterId, setCharacterId] = useState('');
const [isStoryteller, setIsStoryteller] = useState(false);
const handleCreateSession = (sid) => {
setSessionId(sid);
setIsStoryteller(true);
};
const handleJoinSession = (sid, cid) => {
setSessionId(sid);
setCharacterId(cid);
setIsStoryteller(false);
};
if (!sessionId) {
return (
<SessionSetup
onCreateSession={handleCreateSession}
onJoinSession={handleJoinSession}
/>
);
}
if (isStoryteller) {
return <StorytellerView sessionId={sessionId} />;
}
return <CharacterView sessionId={sessionId} characterId={characterId} />;
}
export default App;

View File

@@ -0,0 +1,140 @@
import React, { useState, useEffect, useRef } from 'react';
const API_URL = 'http://localhost:8000';
const WS_URL = 'ws://localhost:8000';
function CharacterView({ sessionId, characterId }) {
const [messages, setMessages] = useState([]);
const [inputMessage, setInputMessage] = useState('');
const [isConnected, setIsConnected] = useState(false);
const [characterInfo, setCharacterInfo] = useState(null);
const [currentScene, setCurrentScene] = useState('');
const wsRef = useRef(null);
const messagesEndRef = useRef(null);
useEffect(() => {
// Fetch character info
fetch(`${API_URL}/sessions/${sessionId}/characters/${characterId}/conversation`)
.then(res => res.json())
.then(data => {
setCharacterInfo(data.character);
setMessages(data.conversation || []);
})
.catch(err => console.error('Error fetching character:', err));
// Connect to WebSocket
const ws = new WebSocket(`${WS_URL}/ws/character/${sessionId}/${characterId}`);
ws.onopen = () => {
console.log('Connected to WebSocket');
setIsConnected(true);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'history') {
setMessages(data.messages || []);
} else if (data.type === 'storyteller_response') {
setMessages(prev => [...prev, data.message]);
} else if (data.type === 'scene_narration') {
setCurrentScene(data.content);
}
};
ws.onclose = () => {
console.log('Disconnected from WebSocket');
setIsConnected(false);
};
wsRef.current = ws;
return () => {
ws.close();
};
}, [sessionId, characterId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = (e) => {
e.preventDefault();
if (!inputMessage.trim() || !isConnected) return;
const message = {
type: 'message',
content: inputMessage
};
wsRef.current.send(JSON.stringify(message));
setMessages(prev => [...prev, { sender: 'character', content: inputMessage, timestamp: new Date().toISOString() }]);
setInputMessage('');
};
return (
<div className="character-view">
<div className="character-header">
<div className="character-info">
<h2>{characterInfo?.name || 'Loading...'}</h2>
<p className="character-description">{characterInfo?.description}</p>
{characterInfo?.personality && (
<p className="character-personality">🎭 {characterInfo.personality}</p>
)}
</div>
<div className="connection-status">
<span className={`status-indicator ${isConnected ? 'connected' : 'disconnected'}`}>
{isConnected ? '● Connected' : '○ Disconnected'}
</span>
</div>
</div>
{currentScene && (
<div className="current-scene">
<h3>📜 Current Scene</h3>
<p>{currentScene}</p>
</div>
)}
<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>
</div>
<div className="message-content">{msg.content}</div>
</div>
))
)}
<div ref={messagesEndRef} />
</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>
</div>
);
}
export default CharacterView;

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
const API_URL = 'http://localhost:8000';
function SessionSetup({ onCreateSession, onJoinSession }) {
const [sessionName, setSessionName] = useState('');
const [joinSessionId, setJoinSessionId] = useState('');
const [characterName, setCharacterName] = useState('');
const [characterDesc, setCharacterDesc] = useState('');
const [characterPersonality, setCharacterPersonality] = useState('');
const [selectedModel, setSelectedModel] = useState('gpt-3.5-turbo');
const [availableModels, setAvailableModels] = useState({ openai: [], openrouter: [] });
useEffect(() => {
// Fetch available models
fetch(`${API_URL}/models`)
.then(res => res.json())
.then(data => {
setAvailableModels(data);
// Set default model based on what's available
if (data.openai.length > 0) {
setSelectedModel(data.openai[0].id);
} else if (data.openrouter.length > 0) {
setSelectedModel(data.openrouter[0].id);
}
})
.catch(err => console.error('Error fetching models:', err));
}, []);
const createSession = async () => {
if (!sessionName.trim()) {
alert('Please enter a session name');
return;
}
try {
const params = new URLSearchParams({ name: sessionName });
const response = await fetch(`${API_URL}/sessions/?${params}`, {
method: 'POST',
});
const data = await response.json();
onCreateSession(data.id);
} catch (error) {
console.error('Error creating session:', error);
alert('Failed to create session');
}
};
const joinSession = async () => {
if (!joinSessionId.trim() || !characterName.trim() || !characterDesc.trim()) {
alert('Please fill in all required fields');
return;
}
try {
const response = await fetch(`${API_URL}/sessions/${joinSessionId}`);
if (!response.ok) {
alert('Session not found');
return;
}
const params = new URLSearchParams({
name: characterName,
description: characterDesc,
personality: characterPersonality,
llm_model: selectedModel,
});
const charResponse = await fetch(`${API_URL}/sessions/${joinSessionId}/characters/?${params}`, {
method: 'POST',
});
const charData = await charResponse.json();
onJoinSession(joinSessionId, charData.id);
} catch (error) {
console.error('Error joining session:', error);
alert('Failed to join session');
}
};
return (
<div className="session-setup">
<div className="setup-container">
<h1>🎭 Storyteller RPG</h1>
<p className="subtitle">Private character-storyteller interactions</p>
<div className="setup-section">
<h2>Create New Session</h2>
<p className="section-description">Start a new game as the storyteller</p>
<div className="input-group">
<input
type="text"
placeholder="Enter session name"
value={sessionName}
onChange={(e) => setSessionName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && createSession()}
/>
<button className="btn-primary" onClick={createSession}>
Create Session
</button>
</div>
</div>
<div className="divider">
<span>OR</span>
</div>
<div className="setup-section">
<h2>Join Existing Session</h2>
<p className="section-description">Play as a character in an ongoing game</p>
<div className="input-group">
<input
type="text"
placeholder="Session ID"
value={joinSessionId}
onChange={(e) => setJoinSessionId(e.target.value)}
/>
<input
type="text"
placeholder="Character Name *"
value={characterName}
onChange={(e) => setCharacterName(e.target.value)}
/>
<textarea
placeholder="Character Description (e.g., A brave knight seeking redemption) *"
value={characterDesc}
onChange={(e) => setCharacterDesc(e.target.value)}
rows="3"
/>
<textarea
placeholder="Personality Traits (optional)"
value={characterPersonality}
onChange={(e) => setCharacterPersonality(e.target.value)}
rows="2"
/>
<div className="model-selector">
<label>🤖 Character AI Model</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="model-select"
>
{availableModels.openai.length > 0 && (
<optgroup label="OpenAI Models">
{availableModels.openai.map(model => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</optgroup>
)}
{availableModels.openrouter.length > 0 && (
<optgroup label="OpenRouter Models">
{availableModels.openrouter.map(model => (
<option key={model.id} value={model.id}>
{model.name} ({model.provider})
</option>
))}
</optgroup>
)}
</select>
<p className="model-hint">
Different models give different personalities! Try Claude for creative responses,
GPT-4 for detailed reasoning, or Llama for faster interactions.
</p>
</div>
<button className="btn-primary" onClick={joinSession}>
Join Session
</button>
</div>
</div>
</div>
</div>
);
}
export default SessionSetup;

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

11
frontend/src/index.js Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './App.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);