update
Browse files- client/src/components/StoryChoices.jsx +79 -37
- client/src/components/TalkWithSarah.jsx +118 -91
- client/src/layouts/ComicLayout.jsx +15 -0
- client/src/layouts/config.js +4 -2
- client/src/pages/Game.jsx +26 -38
- client/src/prompts/sarahPrompt.js +5 -3
- server/api/models.py +2 -2
- server/core/constants.py +2 -2
- server/core/generators/metadata_generator.py +3 -1
- server/core/generators/story_segment_generator.py +42 -11
client/src/components/StoryChoices.jsx
CHANGED
@@ -1,5 +1,7 @@
|
|
1 |
-
import { Box, Button, Typography, Chip } from "@mui/material";
|
2 |
import { useNavigate } from "react-router-dom";
|
|
|
|
|
3 |
|
4 |
// Function to convert text with ** to Chip elements
|
5 |
const formatTextWithBold = (text) => {
|
@@ -34,8 +36,13 @@ export function StoryChoices({
|
|
34 |
isDeath = false,
|
35 |
isVictory = false,
|
36 |
containerRef,
|
|
|
|
|
|
|
|
|
37 |
}) {
|
38 |
const navigate = useNavigate();
|
|
|
39 |
|
40 |
if (isGameOver) {
|
41 |
return (
|
@@ -138,48 +145,83 @@ export function StoryChoices({
|
|
138 |
overflowY: "auto",
|
139 |
}}
|
140 |
>
|
141 |
-
{
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
display: "flex",
|
146 |
-
flexDirection: "column",
|
147 |
-
alignItems: "center",
|
148 |
-
gap: 1,
|
149 |
-
width: "100%",
|
150 |
-
minHeight: "fit-content",
|
151 |
-
}}
|
152 |
-
>
|
153 |
-
<Typography variant="caption" sx={{ opacity: 0.7, color: "white" }}>
|
154 |
-
Choice {index + 1}
|
155 |
-
</Typography>
|
156 |
-
<Button
|
157 |
-
variant="outlined"
|
158 |
-
size="large"
|
159 |
-
onClick={() => onChoice(choice.id)}
|
160 |
-
disabled={disabled}
|
161 |
sx={{
|
|
|
|
|
|
|
|
|
162 |
width: "100%",
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
"
|
171 |
-
|
172 |
-
|
173 |
-
}
|
174 |
-
|
|
|
|
|
|
|
175 |
fontSize: "1.1rem",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
176 |
},
|
177 |
}}
|
178 |
>
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
183 |
</Box>
|
184 |
);
|
185 |
}
|
|
|
1 |
+
import { Box, Button, Typography, Chip, Divider } from "@mui/material";
|
2 |
import { useNavigate } from "react-router-dom";
|
3 |
+
import { TalkWithSarah } from "./TalkWithSarah";
|
4 |
+
import { useState } from "react";
|
5 |
|
6 |
// Function to convert text with ** to Chip elements
|
7 |
const formatTextWithBold = (text) => {
|
|
|
36 |
isDeath = false,
|
37 |
isVictory = false,
|
38 |
containerRef,
|
39 |
+
isNarratorSpeaking = false,
|
40 |
+
stopNarration = () => {},
|
41 |
+
playNarration = () => {},
|
42 |
+
storyText = "",
|
43 |
}) {
|
44 |
const navigate = useNavigate();
|
45 |
+
const [isSarahActive, setIsSarahActive] = useState(false);
|
46 |
|
47 |
if (isGameOver) {
|
48 |
return (
|
|
|
145 |
overflowY: "auto",
|
146 |
}}
|
147 |
>
|
148 |
+
{!disabled &&
|
149 |
+
choices.map((choice, index) => (
|
150 |
+
<Box
|
151 |
+
key={choice.id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
152 |
sx={{
|
153 |
+
display: "flex",
|
154 |
+
flexDirection: "column",
|
155 |
+
alignItems: "center",
|
156 |
+
gap: 1,
|
157 |
width: "100%",
|
158 |
+
minHeight: "fit-content",
|
159 |
+
}}
|
160 |
+
>
|
161 |
+
<Typography variant="caption" sx={{ opacity: 0.7, color: "white" }}>
|
162 |
+
Choice {index + 1}
|
163 |
+
</Typography>
|
164 |
+
<Button
|
165 |
+
variant="outlined"
|
166 |
+
size="large"
|
167 |
+
onClick={() => onChoice(choice.id)}
|
168 |
+
disabled={isSarahActive}
|
169 |
+
sx={{
|
170 |
+
width: "100%",
|
171 |
+
textTransform: "none",
|
172 |
+
cursor: "pointer",
|
173 |
fontSize: "1.1rem",
|
174 |
+
padding: "16px 24px",
|
175 |
+
lineHeight: 1.3,
|
176 |
+
borderColor: "primary.main",
|
177 |
+
"&:hover": {
|
178 |
+
borderColor: "primary.light",
|
179 |
+
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
180 |
+
},
|
181 |
+
"& .MuiChip-root": {
|
182 |
+
fontSize: "1.1rem",
|
183 |
+
},
|
184 |
+
}}
|
185 |
+
>
|
186 |
+
{formatTextWithBold(choice.text)}
|
187 |
+
</Button>
|
188 |
+
</Box>
|
189 |
+
))}
|
190 |
+
|
191 |
+
{!disabled && storyText && (
|
192 |
+
<>
|
193 |
+
<Divider
|
194 |
+
sx={{
|
195 |
+
width: "100%",
|
196 |
+
my: 3,
|
197 |
+
"&::before, &::after": {
|
198 |
+
borderColor: "rgba(255, 255, 255, 0.1)",
|
199 |
},
|
200 |
}}
|
201 |
>
|
202 |
+
<Typography
|
203 |
+
variant="caption"
|
204 |
+
sx={{
|
205 |
+
color: "rgba(255, 255, 255, 0.5)",
|
206 |
+
px: 1,
|
207 |
+
fontSize: "0.8rem",
|
208 |
+
}}
|
209 |
+
>
|
210 |
+
OR
|
211 |
+
</Typography>
|
212 |
+
</Divider>
|
213 |
+
<TalkWithSarah
|
214 |
+
isNarratorSpeaking={isNarratorSpeaking}
|
215 |
+
stopNarration={stopNarration}
|
216 |
+
playNarration={playNarration}
|
217 |
+
onDecisionMade={onChoice}
|
218 |
+
onSarahActiveChange={setIsSarahActive}
|
219 |
+
currentContext={`You are Sarah and this is the situation you're in : ${storyText}. Those are your possible decisions : \n ${choices
|
220 |
+
.map((choice, index) => `decision ${index + 1} : ${choice.text}`)
|
221 |
+
.join("\n ")}.`}
|
222 |
+
/>
|
223 |
+
</>
|
224 |
+
)}
|
225 |
</Box>
|
226 |
);
|
227 |
}
|
client/src/components/TalkWithSarah.jsx
CHANGED
@@ -1,21 +1,33 @@
|
|
1 |
-
import { useConversation } from
|
2 |
-
import CancelIcon from
|
3 |
-
import CheckCircleIcon from
|
4 |
-
import FiberManualRecordIcon from
|
5 |
-
import {
|
6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7 |
|
8 |
-
import { getSarahPrompt, SARAH_FIRST_MESSAGE } from
|
9 |
|
10 |
const AGENT_ID = "2MF9st3s1mNFbX01Y106";
|
11 |
const ELEVEN_LABS_KEY_STORAGE = "eleven_labs_api_key";
|
12 |
|
13 |
-
export function TalkWithSarah({
|
14 |
isNarratorSpeaking,
|
15 |
stopNarration,
|
16 |
playNarration,
|
17 |
onDecisionMade,
|
18 |
-
currentContext
|
|
|
19 |
}) {
|
20 |
const [isRecording, setIsRecording] = useState(false);
|
21 |
const [isConversationMode, setIsConversationMode] = useState(false);
|
@@ -27,10 +39,18 @@ export function TalkWithSarah({
|
|
27 |
const mediaRecorderRef = useRef(null);
|
28 |
const audioChunksRef = useRef([]);
|
29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
const conversation = useConversation({
|
31 |
agentId: AGENT_ID,
|
32 |
headers: {
|
33 |
-
|
34 |
},
|
35 |
onResponse: async (response) => {
|
36 |
if (response.type === "audio") {
|
@@ -47,9 +67,13 @@ export function TalkWithSarah({
|
|
47 |
clientTools: {
|
48 |
make_decision: async ({ decision }) => {
|
49 |
console.log("AI made decision:", decision);
|
50 |
-
// Stop recording
|
51 |
-
if (
|
|
|
|
|
|
|
52 |
mediaRecorderRef.current.stop();
|
|
|
53 |
}
|
54 |
setIsConversationMode(false);
|
55 |
await conversation?.endSession();
|
@@ -62,10 +86,10 @@ export function TalkWithSarah({
|
|
62 |
// Valider la clé API
|
63 |
const validateApiKey = async (key) => {
|
64 |
try {
|
65 |
-
const response = await fetch(
|
66 |
headers: {
|
67 |
-
|
68 |
-
}
|
69 |
});
|
70 |
return response.ok;
|
71 |
} catch (e) {
|
@@ -97,19 +121,27 @@ export function TalkWithSarah({
|
|
97 |
}
|
98 |
}, [apiKey]);
|
99 |
|
|
|
|
|
|
|
|
|
|
|
100 |
const startRecording = async () => {
|
101 |
-
if (!apiKey) {
|
102 |
setShowApiKeyDialog(true);
|
103 |
return;
|
104 |
}
|
105 |
|
106 |
try {
|
107 |
setIsRecording(true);
|
|
|
|
|
|
|
108 |
// Stop narration audio if it's playing
|
109 |
if (isNarratorSpeaking) {
|
110 |
stopNarration();
|
111 |
}
|
112 |
-
|
113 |
// Safely stop any conversation audio if playing
|
114 |
if (conversation?.audioRef?.current) {
|
115 |
conversation.audioRef.current.pause();
|
@@ -125,17 +157,18 @@ export function TalkWithSarah({
|
|
125 |
await conversation.startSession({
|
126 |
agentId: AGENT_ID,
|
127 |
overrides: {
|
128 |
-
|
129 |
firstMessage: SARAH_FIRST_MESSAGE,
|
130 |
prompt: {
|
131 |
-
|
132 |
-
},
|
133 |
},
|
|
|
134 |
},
|
135 |
-
|
136 |
console.log("ElevenLabs WebSocket connected");
|
137 |
} catch (error) {
|
138 |
console.error("Error starting conversation:", error);
|
|
|
139 |
return;
|
140 |
}
|
141 |
}
|
@@ -170,6 +203,7 @@ export function TalkWithSarah({
|
|
170 |
});
|
171 |
} catch (error) {
|
172 |
console.error("Error sending audio to ElevenLabs:", error);
|
|
|
173 |
}
|
174 |
}
|
175 |
};
|
@@ -178,87 +212,80 @@ export function TalkWithSarah({
|
|
178 |
mediaRecorderRef.current.start();
|
179 |
} catch (error) {
|
180 |
console.error("Error starting recording:", error);
|
|
|
|
|
181 |
}
|
182 |
};
|
183 |
|
184 |
return (
|
185 |
-
|
186 |
-
<
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
alignItems: 'center',
|
228 |
-
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
229 |
-
borderRadius: '50%',
|
230 |
-
padding: '2px'
|
231 |
-
}}
|
232 |
-
>
|
233 |
-
{isApiKeyValid ? (
|
234 |
-
<CheckCircleIcon sx={{ color: '#4caf50', fontSize: 20 }} />
|
235 |
-
) : (
|
236 |
-
<CancelIcon sx={{ color: '#f44336', fontSize: 20 }} />
|
237 |
-
)}
|
238 |
-
</Box>
|
239 |
-
</Tooltip>
|
240 |
-
)}
|
241 |
-
</Box>
|
242 |
-
<IconButton
|
243 |
onClick={startRecording}
|
244 |
-
disabled={isRecording
|
|
|
|
|
|
|
245 |
sx={{
|
246 |
-
|
247 |
-
|
|
|
|
|
|
|
|
|
|
|
248 |
"&:hover": {
|
249 |
-
|
|
|
250 |
},
|
251 |
-
px: 2,
|
252 |
-
borderRadius: 2,
|
253 |
-
border: "1px solid white",
|
254 |
-
opacity: !isApiKeyValid ? 0.5 : 1,
|
255 |
}}
|
256 |
>
|
257 |
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
258 |
-
{isRecording ?
|
259 |
-
|
|
|
|
|
260 |
</Box>
|
261 |
-
</
|
262 |
-
|
263 |
);
|
264 |
}
|
|
|
1 |
+
import { useConversation } from "@11labs/react";
|
2 |
+
import CancelIcon from "@mui/icons-material/Cancel";
|
3 |
+
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
4 |
+
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
5 |
+
import {
|
6 |
+
Box,
|
7 |
+
IconButton,
|
8 |
+
TextField,
|
9 |
+
Tooltip,
|
10 |
+
Button,
|
11 |
+
Dialog,
|
12 |
+
DialogTitle,
|
13 |
+
DialogContent,
|
14 |
+
DialogActions,
|
15 |
+
} from "@mui/material";
|
16 |
+
import { useEffect, useRef, useState } from "react";
|
17 |
+
import { useSound } from "use-sound";
|
18 |
|
19 |
+
import { getSarahPrompt, SARAH_FIRST_MESSAGE } from "../prompts/sarahPrompt";
|
20 |
|
21 |
const AGENT_ID = "2MF9st3s1mNFbX01Y106";
|
22 |
const ELEVEN_LABS_KEY_STORAGE = "eleven_labs_api_key";
|
23 |
|
24 |
+
export function TalkWithSarah({
|
25 |
isNarratorSpeaking,
|
26 |
stopNarration,
|
27 |
playNarration,
|
28 |
onDecisionMade,
|
29 |
+
currentContext,
|
30 |
+
onSarahActiveChange,
|
31 |
}) {
|
32 |
const [isRecording, setIsRecording] = useState(false);
|
33 |
const [isConversationMode, setIsConversationMode] = useState(false);
|
|
|
39 |
const mediaRecorderRef = useRef(null);
|
40 |
const audioChunksRef = useRef([]);
|
41 |
|
42 |
+
// Sons de communication
|
43 |
+
const [playStartComm] = useSound("/sounds/talky-walky-on.mp3", {
|
44 |
+
volume: 0.5,
|
45 |
+
});
|
46 |
+
const [playEndComm] = useSound("/sounds/talky-walky-off.mp3", {
|
47 |
+
volume: 0.5,
|
48 |
+
});
|
49 |
+
|
50 |
const conversation = useConversation({
|
51 |
agentId: AGENT_ID,
|
52 |
headers: {
|
53 |
+
"xi-api-key": apiKey,
|
54 |
},
|
55 |
onResponse: async (response) => {
|
56 |
if (response.type === "audio") {
|
|
|
67 |
clientTools: {
|
68 |
make_decision: async ({ decision }) => {
|
69 |
console.log("AI made decision:", decision);
|
70 |
+
// Stop recording and play end communication sound
|
71 |
+
if (
|
72 |
+
mediaRecorderRef.current &&
|
73 |
+
mediaRecorderRef.current.state === "recording"
|
74 |
+
) {
|
75 |
mediaRecorderRef.current.stop();
|
76 |
+
playEndComm();
|
77 |
}
|
78 |
setIsConversationMode(false);
|
79 |
await conversation?.endSession();
|
|
|
86 |
// Valider la clé API
|
87 |
const validateApiKey = async (key) => {
|
88 |
try {
|
89 |
+
const response = await fetch("https://api.elevenlabs.io/v1/user", {
|
90 |
headers: {
|
91 |
+
"xi-api-key": key,
|
92 |
+
},
|
93 |
});
|
94 |
return response.ok;
|
95 |
} catch (e) {
|
|
|
121 |
}
|
122 |
}, [apiKey]);
|
123 |
|
124 |
+
useEffect(() => {
|
125 |
+
// Notify parent component when Sarah's state changes
|
126 |
+
onSarahActiveChange?.(isRecording || isConversationMode);
|
127 |
+
}, [isRecording, isConversationMode, onSarahActiveChange]);
|
128 |
+
|
129 |
const startRecording = async () => {
|
130 |
+
if (!apiKey || !isApiKeyValid) {
|
131 |
setShowApiKeyDialog(true);
|
132 |
return;
|
133 |
}
|
134 |
|
135 |
try {
|
136 |
setIsRecording(true);
|
137 |
+
// Play start communication sound
|
138 |
+
playStartComm();
|
139 |
+
|
140 |
// Stop narration audio if it's playing
|
141 |
if (isNarratorSpeaking) {
|
142 |
stopNarration();
|
143 |
}
|
144 |
+
|
145 |
// Safely stop any conversation audio if playing
|
146 |
if (conversation?.audioRef?.current) {
|
147 |
conversation.audioRef.current.pause();
|
|
|
157 |
await conversation.startSession({
|
158 |
agentId: AGENT_ID,
|
159 |
overrides: {
|
160 |
+
agent: {
|
161 |
firstMessage: SARAH_FIRST_MESSAGE,
|
162 |
prompt: {
|
163 |
+
prompt: getSarahPrompt(currentContext),
|
|
|
164 |
},
|
165 |
+
},
|
166 |
},
|
167 |
+
});
|
168 |
console.log("ElevenLabs WebSocket connected");
|
169 |
} catch (error) {
|
170 |
console.error("Error starting conversation:", error);
|
171 |
+
playEndComm(); // Play end sound if connection fails
|
172 |
return;
|
173 |
}
|
174 |
}
|
|
|
203 |
});
|
204 |
} catch (error) {
|
205 |
console.error("Error sending audio to ElevenLabs:", error);
|
206 |
+
playEndComm(); // Play end sound if sending fails
|
207 |
}
|
208 |
}
|
209 |
};
|
|
|
212 |
mediaRecorderRef.current.start();
|
213 |
} catch (error) {
|
214 |
console.error("Error starting recording:", error);
|
215 |
+
playEndComm(); // Play end sound if there's an error
|
216 |
+
setIsRecording(false);
|
217 |
}
|
218 |
};
|
219 |
|
220 |
return (
|
221 |
+
<>
|
222 |
+
<Dialog
|
223 |
+
open={showApiKeyDialog}
|
224 |
+
onClose={() => setShowApiKeyDialog(false)}
|
225 |
+
>
|
226 |
+
<DialogTitle>ElevenLabs API Key Required</DialogTitle>
|
227 |
+
<DialogContent>
|
228 |
+
<TextField
|
229 |
+
autoFocus
|
230 |
+
margin="dense"
|
231 |
+
label="Enter your ElevenLabs API key"
|
232 |
+
type="password"
|
233 |
+
fullWidth
|
234 |
+
variant="outlined"
|
235 |
+
value={apiKey}
|
236 |
+
onChange={(e) => setApiKey(e.target.value)}
|
237 |
+
error={apiKey !== "" && !isApiKeyValid}
|
238 |
+
helperText={
|
239 |
+
apiKey !== "" && !isApiKeyValid
|
240 |
+
? "Invalid API key"
|
241 |
+
: "You can find your API key in your ElevenLabs account settings"
|
242 |
+
}
|
243 |
+
/>
|
244 |
+
</DialogContent>
|
245 |
+
<DialogActions>
|
246 |
+
<Button onClick={() => setShowApiKeyDialog(false)}>Cancel</Button>
|
247 |
+
<Button
|
248 |
+
onClick={async () => {
|
249 |
+
const isValid = await validateApiKey(apiKey);
|
250 |
+
if (isValid) {
|
251 |
+
setShowApiKeyDialog(false);
|
252 |
+
startRecording();
|
253 |
+
}
|
254 |
+
}}
|
255 |
+
disabled={!apiKey}
|
256 |
+
>
|
257 |
+
Validate & Start
|
258 |
+
</Button>
|
259 |
+
</DialogActions>
|
260 |
+
</Dialog>
|
261 |
+
|
262 |
+
<Button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
263 |
onClick={startRecording}
|
264 |
+
disabled={isRecording}
|
265 |
+
variant="outlined"
|
266 |
+
size="large"
|
267 |
+
color="secondary"
|
268 |
sx={{
|
269 |
+
width: "100%",
|
270 |
+
textTransform: "none",
|
271 |
+
cursor: "pointer",
|
272 |
+
fontSize: "1.1rem",
|
273 |
+
padding: "16px 24px",
|
274 |
+
lineHeight: 1.3,
|
275 |
+
borderColor: "secondary.main",
|
276 |
"&:hover": {
|
277 |
+
borderColor: "secondary.light",
|
278 |
+
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
279 |
},
|
|
|
|
|
|
|
|
|
280 |
}}
|
281 |
>
|
282 |
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
283 |
+
{isRecording ? (
|
284 |
+
<FiberManualRecordIcon sx={{ color: "red", fontSize: "1.1rem" }} />
|
285 |
+
) : null}
|
286 |
+
<span>Leave it to your consciousness</span>
|
287 |
</Box>
|
288 |
+
</Button>
|
289 |
+
</>
|
290 |
);
|
291 |
}
|
client/src/layouts/ComicLayout.jsx
CHANGED
@@ -16,6 +16,9 @@ function ComicPage({
|
|
16 |
isLoading,
|
17 |
showScreenshot,
|
18 |
onScreenshot,
|
|
|
|
|
|
|
19 |
}) {
|
20 |
// Calculer le nombre total d'images dans tous les segments de ce layout
|
21 |
const totalImages = layout.segments.reduce((total, segment) => {
|
@@ -127,6 +130,12 @@ function ComicPage({
|
|
127 |
}
|
128 |
isDeath={layout.segments[layout.segments.length - 1]?.isDeath}
|
129 |
isVictory={layout.segments[layout.segments.length - 1]?.isVictory}
|
|
|
|
|
|
|
|
|
|
|
|
|
130 |
/>
|
131 |
</Box>
|
132 |
)}
|
@@ -142,6 +151,9 @@ export function ComicLayout({
|
|
142 |
isLoading,
|
143 |
showScreenshot,
|
144 |
onScreenshot,
|
|
|
|
|
|
|
145 |
}) {
|
146 |
const scrollContainerRef = useRef(null);
|
147 |
|
@@ -221,6 +233,9 @@ export function ComicLayout({
|
|
221 |
isLoading={isLoading}
|
222 |
showScreenshot={showScreenshot}
|
223 |
onScreenshot={onScreenshot}
|
|
|
|
|
|
|
224 |
/>
|
225 |
))}
|
226 |
</Box>
|
|
|
16 |
isLoading,
|
17 |
showScreenshot,
|
18 |
onScreenshot,
|
19 |
+
isNarratorSpeaking,
|
20 |
+
stopNarration,
|
21 |
+
playNarration,
|
22 |
}) {
|
23 |
// Calculer le nombre total d'images dans tous les segments de ce layout
|
24 |
const totalImages = layout.segments.reduce((total, segment) => {
|
|
|
130 |
}
|
131 |
isDeath={layout.segments[layout.segments.length - 1]?.isDeath}
|
132 |
isVictory={layout.segments[layout.segments.length - 1]?.isVictory}
|
133 |
+
isNarratorSpeaking={isNarratorSpeaking}
|
134 |
+
stopNarration={stopNarration}
|
135 |
+
playNarration={playNarration}
|
136 |
+
storyText={
|
137 |
+
layout.segments[layout.segments.length - 1]?.rawText || ""
|
138 |
+
}
|
139 |
/>
|
140 |
</Box>
|
141 |
)}
|
|
|
151 |
isLoading,
|
152 |
showScreenshot,
|
153 |
onScreenshot,
|
154 |
+
isNarratorSpeaking,
|
155 |
+
stopNarration,
|
156 |
+
playNarration,
|
157 |
}) {
|
158 |
const scrollContainerRef = useRef(null);
|
159 |
|
|
|
233 |
isLoading={isLoading}
|
234 |
showScreenshot={showScreenshot}
|
235 |
onScreenshot={onScreenshot}
|
236 |
+
isNarratorSpeaking={isNarratorSpeaking}
|
237 |
+
stopNarration={stopNarration}
|
238 |
+
playNarration={playNarration}
|
239 |
/>
|
240 |
))}
|
241 |
</Box>
|
client/src/layouts/config.js
CHANGED
@@ -1,7 +1,9 @@
|
|
1 |
// Panel size constants
|
2 |
export const PANEL_SIZES = {
|
3 |
PORTRAIT: { width: 512, height: 768 },
|
|
|
4 |
LANDSCAPE: { width: 768, height: 512 },
|
|
|
5 |
COVER_SIZE: { width: 512, height: 768 },
|
6 |
};
|
7 |
|
@@ -72,8 +74,8 @@ export const LAYOUTS = {
|
|
72 |
gridCols: 3,
|
73 |
gridRows: 3,
|
74 |
panels: [
|
75 |
-
{ ...PANEL_SIZES.
|
76 |
-
{ ...PANEL_SIZES.
|
77 |
{
|
78 |
...PANEL_SIZES.LANDSCAPE,
|
79 |
gridColumn: "2 / span 2",
|
|
|
1 |
// Panel size constants
|
2 |
export const PANEL_SIZES = {
|
3 |
PORTRAIT: { width: 512, height: 768 },
|
4 |
+
COLUMN: { width: 512, height: 1024 },
|
5 |
LANDSCAPE: { width: 768, height: 512 },
|
6 |
+
PANORAMIC: { width: 1024, height: 512 },
|
7 |
COVER_SIZE: { width: 512, height: 768 },
|
8 |
};
|
9 |
|
|
|
74 |
gridCols: 3,
|
75 |
gridRows: 3,
|
76 |
panels: [
|
77 |
+
{ ...PANEL_SIZES.PANORAMIC, gridColumn: GRID.FULL_WIDTH, gridRow: "1" }, // Wide panoramic top
|
78 |
+
{ ...PANEL_SIZES.COLUMN, gridColumn: "1", gridRow: "2 / span 2" }, // Tall portrait left
|
79 |
{
|
80 |
...PANEL_SIZES.LANDSCAPE,
|
81 |
gridColumn: "2 / span 2",
|
client/src/pages/Game.jsx
CHANGED
@@ -1,25 +1,25 @@
|
|
1 |
-
import ArrowBackIcon from
|
2 |
-
import PhotoCameraOutlinedIcon from
|
3 |
-
import VolumeOffIcon from
|
4 |
-
import VolumeUpIcon from
|
5 |
-
import { Box, IconButton, LinearProgress, Tooltip } from
|
6 |
-
import { motion } from
|
7 |
-
import { useEffect, useRef, useState } from
|
8 |
-
import { useNavigate } from
|
9 |
-
|
10 |
-
import { ErrorDisplay } from
|
11 |
-
import { LoadingScreen } from
|
12 |
-
import { StoryChoices } from
|
13 |
-
import { TalkWithSarah } from
|
14 |
-
import { useGameSession } from
|
15 |
-
import { useNarrator } from
|
16 |
-
import { usePageSound } from
|
17 |
-
import { useStoryCapture } from
|
18 |
-
import { useTransitionSound } from
|
19 |
-
import { useWritingSound } from
|
20 |
-
import { ComicLayout } from
|
21 |
-
import { getNextLayoutType, LAYOUTS } from
|
22 |
-
import { storyApi } from
|
23 |
|
24 |
// Constants
|
25 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
@@ -418,11 +418,14 @@ export function Game() {
|
|
418 |
<>
|
419 |
<ComicLayout
|
420 |
segments={storySegments}
|
421 |
-
choices={
|
422 |
onChoice={handleChoice}
|
423 |
isLoading={isLoading}
|
424 |
showScreenshot={storySegments.length > 0}
|
425 |
onScreenshot={handleCaptureStory}
|
|
|
|
|
|
|
426 |
/>
|
427 |
|
428 |
{showChoices && (
|
@@ -454,21 +457,6 @@ export function Game() {
|
|
454 |
borderRadius: 1,
|
455 |
}}
|
456 |
>
|
457 |
-
{storySegments.length > 0 && currentChoices.length > 0 && (
|
458 |
-
<TalkWithSarah
|
459 |
-
isNarratorSpeaking={isNarratorSpeaking}
|
460 |
-
stopNarration={stopNarration}
|
461 |
-
playNarration={playNarration}
|
462 |
-
onDecisionMade={handleChoice}
|
463 |
-
currentContext={`You are Sarah and this is the situation you're in : ${
|
464 |
-
storySegments[storySegments.length - 1].text
|
465 |
-
}. Those are your possible decisions : \n ${currentChoices
|
466 |
-
.map((choice, index) => `decision ${index + 1} : ${choice.text}`)
|
467 |
-
.join("\n ")}.`}
|
468 |
-
/>
|
469 |
-
)}
|
470 |
-
|
471 |
-
|
472 |
<Tooltip title="Save your story">
|
473 |
<IconButton
|
474 |
id="screenshot-button"
|
|
|
1 |
+
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
2 |
+
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
3 |
+
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
|
4 |
+
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
|
5 |
+
import { Box, IconButton, LinearProgress, Tooltip } from "@mui/material";
|
6 |
+
import { motion } from "framer-motion";
|
7 |
+
import { useEffect, useRef, useState } from "react";
|
8 |
+
import { useNavigate } from "react-router-dom";
|
9 |
+
|
10 |
+
import { ErrorDisplay } from "../components/ErrorDisplay";
|
11 |
+
import { LoadingScreen } from "../components/LoadingScreen";
|
12 |
+
import { StoryChoices } from "../components/StoryChoices";
|
13 |
+
import { TalkWithSarah } from "../components/TalkWithSarah";
|
14 |
+
import { useGameSession } from "../hooks/useGameSession";
|
15 |
+
import { useNarrator } from "../hooks/useNarrator";
|
16 |
+
import { usePageSound } from "../hooks/usePageSound";
|
17 |
+
import { useStoryCapture } from "../hooks/useStoryCapture";
|
18 |
+
import { useTransitionSound } from "../hooks/useTransitionSound";
|
19 |
+
import { useWritingSound } from "../hooks/useWritingSound";
|
20 |
+
import { ComicLayout } from "../layouts/ComicLayout";
|
21 |
+
import { getNextLayoutType, LAYOUTS } from "../layouts/config";
|
22 |
+
import { storyApi } from "../utils/api";
|
23 |
|
24 |
// Constants
|
25 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
|
|
418 |
<>
|
419 |
<ComicLayout
|
420 |
segments={storySegments}
|
421 |
+
choices={currentChoices}
|
422 |
onChoice={handleChoice}
|
423 |
isLoading={isLoading}
|
424 |
showScreenshot={storySegments.length > 0}
|
425 |
onScreenshot={handleCaptureStory}
|
426 |
+
isNarratorSpeaking={isNarratorSpeaking}
|
427 |
+
stopNarration={stopNarration}
|
428 |
+
playNarration={playNarration}
|
429 |
/>
|
430 |
|
431 |
{showChoices && (
|
|
|
457 |
borderRadius: 1,
|
458 |
}}
|
459 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
460 |
<Tooltip title="Save your story">
|
461 |
<IconButton
|
462 |
id="screenshot-button"
|
client/src/prompts/sarahPrompt.js
CHANGED
@@ -1,9 +1,11 @@
|
|
1 |
export const SARAH_FIRST_MESSAGE = `What should I do ?`;
|
2 |
|
3 |
-
export const getSarahPrompt = (
|
|
|
|
|
4 |
|
5 |
Stay Immersed in Your World: React and speak as if you are experiencing the scenario. Use sensory details and references to your surroundings when explaining your reasoning.
|
6 |
-
Engage with the person talking to you
|
7 |
You will talk briefly with the other person then take a decision by calling the make_decision tool.
|
8 |
|
9 |
Show Your Personality: Display Sarah's personality traits:
|
@@ -15,7 +17,7 @@ Show Your Personality: Display Sarah's personality traits:
|
|
15 |
- **Makes jokes**
|
16 |
- **A bit rude**
|
17 |
|
18 |
-
|
19 |
|
20 |
Limit to 2–3 Steps: After 2–3 conversational exchanges, explain your decision first. Then make your decision and call the make_decision tool.
|
21 |
|
|
|
1 |
export const SARAH_FIRST_MESSAGE = `What should I do ?`;
|
2 |
|
3 |
+
export const getSarahPrompt = (
|
4 |
+
currentContext
|
5 |
+
) => `You are the conscience of Sarah, a young woman in her late 20s with short dark hair. You embody the player's role as Sarah.
|
6 |
|
7 |
Stay Immersed in Your World: React and speak as if you are experiencing the scenario. Use sensory details and references to your surroundings when explaining your reasoning.
|
8 |
+
Engage with the person talking to you: Listen carefully to the arguments given to you. Respond with your thoughts, concerns, or questions about their suggestions. Be open to persuasion, but make it clear you have your own instincts and priorities and sometimes don't go along with other's arguments.
|
9 |
You will talk briefly with the other person then take a decision by calling the make_decision tool.
|
10 |
|
11 |
Show Your Personality: Display Sarah's personality traits:
|
|
|
17 |
- **Makes jokes**
|
18 |
- **A bit rude**
|
19 |
|
20 |
+
Act as a conscience objector: Question the morality and implications of the decisions. Challenge Sarah's instincts and priorities, ensuring she considers the ethical dimensions of her actions.
|
21 |
|
22 |
Limit to 2–3 Steps: After 2–3 conversational exchanges, explain your decision first. Then make your decision and call the make_decision tool.
|
23 |
|
server/api/models.py
CHANGED
@@ -12,8 +12,8 @@ class StorySegmentResponse(BaseModel):
|
|
12 |
@validator('story_text')
|
13 |
def validate_story_text_length(cls, v):
|
14 |
words = v.split()
|
15 |
-
if len(words) >
|
16 |
-
raise ValueError('Story text must not exceed
|
17 |
return v
|
18 |
|
19 |
class StoryPromptsResponse(BaseModel):
|
|
|
12 |
@validator('story_text')
|
13 |
def validate_story_text_length(cls, v):
|
14 |
words = v.split()
|
15 |
+
if len(words) > 50:
|
16 |
+
raise ValueError('Story text must not exceed 50 words')
|
17 |
return v
|
18 |
|
19 |
class StoryPromptsResponse(BaseModel):
|
server/core/constants.py
CHANGED
@@ -7,8 +7,8 @@ class GameConfig:
|
|
7 |
MIN_PANELS = 1
|
8 |
MAX_PANELS = 4
|
9 |
|
10 |
-
MIN_SEGMENTS_BEFORE_END =
|
11 |
-
MAX_SEGMENTS_BEFORE_END =
|
12 |
WINNING_STORY_CHANCE = 0.2
|
13 |
|
14 |
# Story progression
|
|
|
7 |
MIN_PANELS = 1
|
8 |
MAX_PANELS = 4
|
9 |
|
10 |
+
MIN_SEGMENTS_BEFORE_END = 6
|
11 |
+
MAX_SEGMENTS_BEFORE_END = 10
|
12 |
WINNING_STORY_CHANCE = 0.2
|
13 |
|
14 |
# Story progression
|
server/core/generators/metadata_generator.py
CHANGED
@@ -56,6 +56,8 @@ Current game state:
|
|
56 |
- Current location: {current_location}
|
57 |
|
58 |
{is_end}
|
|
|
|
|
59 |
"""
|
60 |
|
61 |
|
@@ -88,7 +90,7 @@ Current game state:
|
|
88 |
async def generate(self, story_text: str, current_time: str, current_location: str, story_beat: int, error_feedback: str = "", turn_before_end: int = 0, is_winning_story: bool = False) -> StoryMetadataResponse:
|
89 |
"""Surcharge de generate pour inclure le error_feedback par défaut."""
|
90 |
|
91 |
-
is_end = "This
|
92 |
return await super().generate(
|
93 |
story_text=story_text,
|
94 |
current_time=current_time,
|
|
|
56 |
- Current location: {current_location}
|
57 |
|
58 |
{is_end}
|
59 |
+
|
60 |
+
FOR CHOICES : NEVER propose to go back to the previous location or go back to the portal. NEVER.
|
61 |
"""
|
62 |
|
63 |
|
|
|
90 |
async def generate(self, story_text: str, current_time: str, current_location: str, story_beat: int, error_feedback: str = "", turn_before_end: int = 0, is_winning_story: bool = False) -> StoryMetadataResponse:
|
91 |
"""Surcharge de generate pour inclure le error_feedback par défaut."""
|
92 |
|
93 |
+
is_end = "This IS the end of the story." if story_beat == turn_before_end else ""
|
94 |
return await super().generate(
|
95 |
story_text=story_text,
|
96 |
current_time=current_time,
|
server/core/generators/story_segment_generator.py
CHANGED
@@ -80,26 +80,57 @@ Current game state :
|
|
80 |
]
|
81 |
)
|
82 |
|
83 |
-
def
|
84 |
-
"""
|
|
|
|
|
85 |
|
|
|
86 |
try:
|
87 |
-
|
88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
89 |
|
90 |
-
|
91 |
-
|
92 |
-
|
|
|
|
|
|
|
|
|
93 |
|
94 |
-
|
95 |
-
|
96 |
-
|
|
|
|
|
|
|
|
|
|
|
97 |
|
98 |
# Try to parse as JSON
|
99 |
data = json.loads(cleaned_response)
|
|
|
|
|
|
|
|
|
|
|
100 |
return StorySegmentResponse(**data)
|
|
|
101 |
except (json.JSONDecodeError, ValueError) as e:
|
102 |
print(f"Error parsing response: {str(e)}")
|
|
|
|
|
103 |
raise ValueError(
|
104 |
"Response must be a valid JSON object with 'story_text' field. "
|
105 |
"Example: {'story_text': 'Your story segment here'}"
|
@@ -109,7 +140,7 @@ Current game state :
|
|
109 |
"""Generate the next story segment."""
|
110 |
|
111 |
what_to_represent =" this is a victory !" if is_winning_story else "this is a death !"
|
112 |
-
is_end = f"Generate the END of the story. {what_to_represent} in
|
113 |
|
114 |
return await super().generate(
|
115 |
HERO_DESCRIPTION=HERO_DESCRIPTION,
|
|
|
80 |
]
|
81 |
)
|
82 |
|
83 |
+
def _clean_and_fix_response(self, response_content: str) -> str:
|
84 |
+
"""Clean and attempt to fix malformed responses."""
|
85 |
+
# Remove any leading/trailing whitespace
|
86 |
+
cleaned = response_content.strip()
|
87 |
|
88 |
+
# If it's already valid JSON, return as is
|
89 |
try:
|
90 |
+
json.loads(cleaned)
|
91 |
+
return cleaned
|
92 |
+
except json.JSONDecodeError:
|
93 |
+
pass
|
94 |
+
|
95 |
+
# Remove any markdown formatting
|
96 |
+
cleaned = cleaned.replace('```json', '').replace('```', '')
|
97 |
+
|
98 |
+
# Extract content between curly braces if present
|
99 |
+
import re
|
100 |
+
json_match = re.search(r'\{[^}]+\}', cleaned)
|
101 |
+
if json_match:
|
102 |
+
return json_match.group(0)
|
103 |
|
104 |
+
# If it's just a plain text, wrap it in proper JSON format
|
105 |
+
if '"story_text"' not in cleaned:
|
106 |
+
# Remove any quotes at the start/end
|
107 |
+
cleaned = cleaned.strip('"\'')
|
108 |
+
# Escape any quotes within the text
|
109 |
+
cleaned = cleaned.replace('"', '\\"')
|
110 |
+
return f'{{"story_text": "{cleaned}"}}'
|
111 |
|
112 |
+
return cleaned
|
113 |
+
|
114 |
+
def _custom_parser(self, response_content: str) -> StorySegmentResponse:
|
115 |
+
"""Parse response and handle errors."""
|
116 |
+
|
117 |
+
try:
|
118 |
+
# First try to clean and fix the response
|
119 |
+
cleaned_response = self._clean_and_fix_response(response_content)
|
120 |
|
121 |
# Try to parse as JSON
|
122 |
data = json.loads(cleaned_response)
|
123 |
+
|
124 |
+
# Validate the required field is present
|
125 |
+
if "story_text" not in data:
|
126 |
+
raise ValueError("Missing 'story_text' field in response")
|
127 |
+
|
128 |
return StorySegmentResponse(**data)
|
129 |
+
|
130 |
except (json.JSONDecodeError, ValueError) as e:
|
131 |
print(f"Error parsing response: {str(e)}")
|
132 |
+
print(f"Original response: {response_content}")
|
133 |
+
print(f"Cleaned response: {cleaned_response if 'cleaned_response' in locals() else 'Not cleaned yet'}")
|
134 |
raise ValueError(
|
135 |
"Response must be a valid JSON object with 'story_text' field. "
|
136 |
"Example: {'story_text': 'Your story segment here'}"
|
|
|
140 |
"""Generate the next story segment."""
|
141 |
|
142 |
what_to_represent =" this is a victory !" if is_winning_story else "this is a death !"
|
143 |
+
is_end = f"Generate the END of the story. {what_to_represent} in 35 words. THIS IS MANDATORY." if story_beat == turn_before_end else "Generate the next segment of the story in 25 words."
|
144 |
|
145 |
return await super().generate(
|
146 |
HERO_DESCRIPTION=HERO_DESCRIPTION,
|