Initial commit
This commit is contained in:
39
frontend/package.json
Normal file
39
frontend/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
frontend/public/index.html
Normal file
17
frontend/public/index.html
Normal 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>
|
||||
8
frontend/public/manifest.json
Normal file
8
frontend/public/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"short_name": "Storyteller RPG",
|
||||
"name": "Storyteller RPG Application",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#667eea",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
3
frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
703
frontend/src/App.css
Normal file
703
frontend/src/App.css
Normal 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
39
frontend/src/App.js
Normal 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;
|
||||
140
frontend/src/components/CharacterView.js
Normal file
140
frontend/src/components/CharacterView.js
Normal 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;
|
||||
179
frontend/src/components/SessionSetup.js
Normal file
179
frontend/src/components/SessionSetup.js
Normal 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;
|
||||
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;
|
||||
11
frontend/src/index.js
Normal file
11
frontend/src/index.js
Normal 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>
|
||||
);
|
||||
Reference in New Issue
Block a user