update story
Browse files- client/src/components/GameNavigation.jsx +38 -1
- client/src/components/StoryChoices.jsx +1 -0
- client/src/components/UniverseSlotMachine.jsx +1 -1
- client/src/hooks/useStoryCapture.js +47 -5
- client/src/layouts/ComicLayout.jsx +18 -0
- client/src/layouts/Panel.jsx +5 -2
- client/src/layouts/config.js +113 -20
- client/src/pages/Debug.jsx +72 -89
- client/src/pages/Home.jsx +3 -4
- client/src/pages/Tutorial.jsx +69 -4
- server/api/models.py +1 -0
- server/api/routes/chat.py +29 -33
- server/core/game_state.py +34 -18
- server/core/generators/image_prompt_generator.py +28 -22
- server/core/generators/metadata_generator.py +77 -42
- server/core/generators/story_segment_generator.py +14 -10
- server/core/generators/universe_generator.py +1 -1
- server/core/story_generator.py +9 -6
- server/core/styles/universe_styles.json +0 -1
- server/scripts/test_game.py +25 -26
- server/services/flux_client.py +1 -1
- server/services/mistral_client.py +61 -39
client/src/components/GameNavigation.jsx
CHANGED
@@ -2,8 +2,11 @@ import { IconButton, Tooltip } from "@mui/material";
|
|
2 |
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
3 |
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
|
4 |
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
|
|
|
5 |
import { useNavigate } from "react-router-dom";
|
6 |
import { useSoundSystem } from "../contexts/SoundContext";
|
|
|
|
|
7 |
import { storyApi } from "../utils/api";
|
8 |
|
9 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
@@ -11,6 +14,8 @@ const SOUND_ENABLED_KEY = "sound_enabled";
|
|
11 |
export function GameNavigation() {
|
12 |
const navigate = useNavigate();
|
13 |
const { isSoundEnabled, setIsSoundEnabled, playSound } = useSoundSystem();
|
|
|
|
|
14 |
|
15 |
const handleBack = () => {
|
16 |
playSound("page");
|
@@ -24,8 +29,21 @@ export function GameNavigation() {
|
|
24 |
storyApi.setSoundEnabled(newSoundState);
|
25 |
};
|
26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
return (
|
28 |
-
<div style={{ position: "relative", zIndex: 1000 }}>
|
29 |
{window.location.pathname !== "/" && (
|
30 |
<Tooltip title="Back to home">
|
31 |
<IconButton
|
@@ -48,6 +66,25 @@ export function GameNavigation() {
|
|
48 |
</Tooltip>
|
49 |
)}
|
50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
51 |
<Tooltip title={isSoundEnabled ? "Mute sound" : "Unmute sound"}>
|
52 |
<IconButton
|
53 |
onClick={handleToggleSound}
|
|
|
2 |
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
3 |
import VolumeOffIcon from "@mui/icons-material/VolumeOff";
|
4 |
import VolumeUpIcon from "@mui/icons-material/VolumeUp";
|
5 |
+
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
6 |
import { useNavigate } from "react-router-dom";
|
7 |
import { useSoundSystem } from "../contexts/SoundContext";
|
8 |
+
import { useStoryCapture } from "../hooks/useStoryCapture";
|
9 |
+
import { useRef } from "react";
|
10 |
import { storyApi } from "../utils/api";
|
11 |
|
12 |
const SOUND_ENABLED_KEY = "sound_enabled";
|
|
|
14 |
export function GameNavigation() {
|
15 |
const navigate = useNavigate();
|
16 |
const { isSoundEnabled, setIsSoundEnabled, playSound } = useSoundSystem();
|
17 |
+
const { downloadStoryImage } = useStoryCapture();
|
18 |
+
const containerRef = useRef(null);
|
19 |
|
20 |
const handleBack = () => {
|
21 |
playSound("page");
|
|
|
29 |
storyApi.setSoundEnabled(newSoundState);
|
30 |
};
|
31 |
|
32 |
+
const handleCapture = async () => {
|
33 |
+
playSound("page");
|
34 |
+
const container = document.querySelector(
|
35 |
+
"[data-comic-layout]"
|
36 |
+
)?.parentElement;
|
37 |
+
if (container) {
|
38 |
+
await downloadStoryImage(
|
39 |
+
{ current: container },
|
40 |
+
`your-story-${Date.now()}.png`
|
41 |
+
);
|
42 |
+
}
|
43 |
+
};
|
44 |
+
|
45 |
return (
|
46 |
+
<div style={{ position: "relative", zIndex: 1000 }} ref={containerRef}>
|
47 |
{window.location.pathname !== "/" && (
|
48 |
<Tooltip title="Back to home">
|
49 |
<IconButton
|
|
|
66 |
</Tooltip>
|
67 |
)}
|
68 |
|
69 |
+
<Tooltip title="Capture story">
|
70 |
+
<IconButton
|
71 |
+
onClick={handleCapture}
|
72 |
+
sx={{
|
73 |
+
position: "fixed",
|
74 |
+
top: 24,
|
75 |
+
right: 88,
|
76 |
+
color: "white",
|
77 |
+
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
78 |
+
"&:hover": {
|
79 |
+
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
80 |
+
},
|
81 |
+
pointerEvents: "auto",
|
82 |
+
}}
|
83 |
+
>
|
84 |
+
<PhotoCameraOutlinedIcon />
|
85 |
+
</IconButton>
|
86 |
+
</Tooltip>
|
87 |
+
|
88 |
<Tooltip title={isSoundEnabled ? "Mute sound" : "Unmute sound"}>
|
89 |
<IconButton
|
90 |
onClick={handleToggleSound}
|
client/src/components/StoryChoices.jsx
CHANGED
@@ -112,6 +112,7 @@ export function StoryChoices() {
|
|
112 |
|
113 |
return (
|
114 |
<Box
|
|
|
115 |
sx={{
|
116 |
display: "flex",
|
117 |
flexDirection: isMobile ? "column" : "row",
|
|
|
112 |
|
113 |
return (
|
114 |
<Box
|
115 |
+
data-story-choices
|
116 |
sx={{
|
117 |
display: "flex",
|
118 |
flexDirection: isMobile ? "column" : "row",
|
client/src/components/UniverseSlotMachine.jsx
CHANGED
@@ -209,7 +209,7 @@ export const UniverseSlotMachine = ({
|
|
209 |
}}
|
210 |
>
|
211 |
<SlotSection
|
212 |
-
label="
|
213 |
value={style}
|
214 |
words={RANDOM_STYLES}
|
215 |
delay={0}
|
|
|
209 |
}}
|
210 |
>
|
211 |
<SlotSection
|
212 |
+
label=""
|
213 |
value={style}
|
214 |
words={RANDOM_STYLES}
|
215 |
delay={0}
|
client/src/hooks/useStoryCapture.js
CHANGED
@@ -7,6 +7,38 @@ export function useStoryCapture() {
|
|
7 |
quality: 1.0,
|
8 |
});
|
9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
const captureStory = useCallback(
|
11 |
async (containerRef) => {
|
12 |
if (!containerRef.current) return null;
|
@@ -21,17 +53,24 @@ export function useStoryCapture() {
|
|
21 |
// Save original styles
|
22 |
const originalStyle = element.style.cssText;
|
23 |
const originalScroll = element.scrollLeft;
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
|
25 |
-
// Reset scroll and
|
26 |
Object.assign(element.style, {
|
27 |
-
|
28 |
-
paddingRight: "0",
|
29 |
-
width: `${element.scrollWidth - 975}px`, // Reduce width by choices panel
|
30 |
display: "flex",
|
31 |
flexDirection: "row",
|
32 |
gap: "32px",
|
33 |
padding: "32px",
|
|
|
|
|
34 |
overflow: "hidden",
|
|
|
35 |
});
|
36 |
element.scrollLeft = 0;
|
37 |
|
@@ -41,7 +80,7 @@ export function useStoryCapture() {
|
|
41 |
// Take screenshot
|
42 |
const result = await takeScreenshot(element, {
|
43 |
backgroundColor: "#242424",
|
44 |
-
width:
|
45 |
height: element.scrollHeight,
|
46 |
style: {
|
47 |
transform: "none",
|
@@ -51,6 +90,9 @@ export function useStoryCapture() {
|
|
51 |
|
52 |
// Restore original styles
|
53 |
element.style.cssText = originalStyle;
|
|
|
|
|
|
|
54 |
element.scrollLeft = originalScroll;
|
55 |
|
56 |
return result;
|
|
|
7 |
quality: 1.0,
|
8 |
});
|
9 |
|
10 |
+
const calculateOptimalWidth = (element) => {
|
11 |
+
// Get all comic pages
|
12 |
+
const comicPages = element.querySelectorAll("[data-comic-page]");
|
13 |
+
if (!comicPages.length) return element.scrollWidth;
|
14 |
+
|
15 |
+
// Get width of a single page (they all have the same width)
|
16 |
+
const firstPage = comicPages[0];
|
17 |
+
const pageWidth = firstPage.offsetWidth;
|
18 |
+
const gap = 32; // Fixed gap between pages
|
19 |
+
const padding = 32; // Fixed padding on both sides
|
20 |
+
|
21 |
+
// Calculate total width:
|
22 |
+
// - All pages width (pageWidth * nbPages)
|
23 |
+
// - Gaps only between pages, so (nbPages - 1) gaps
|
24 |
+
// - Padding on both sides
|
25 |
+
const totalWidth =
|
26 |
+
pageWidth * comicPages.length +
|
27 |
+
(comicPages.length > 1 ? gap * (comicPages.length - 1) : 0) +
|
28 |
+
padding * 2;
|
29 |
+
|
30 |
+
console.log("Width calculation:", {
|
31 |
+
numberOfPages: comicPages.length,
|
32 |
+
pageWidth,
|
33 |
+
gapBetweenPages: gap,
|
34 |
+
totalGaps: comicPages.length > 1 ? gap * (comicPages.length - 1) : 0,
|
35 |
+
padding,
|
36 |
+
totalWidth,
|
37 |
+
});
|
38 |
+
|
39 |
+
return totalWidth;
|
40 |
+
};
|
41 |
+
|
42 |
const captureStory = useCallback(
|
43 |
async (containerRef) => {
|
44 |
if (!containerRef.current) return null;
|
|
|
53 |
// Save original styles
|
54 |
const originalStyle = element.style.cssText;
|
55 |
const originalScroll = element.scrollLeft;
|
56 |
+
const originalWidth = element.style.width;
|
57 |
+
const originalPadding = element.style.padding;
|
58 |
+
const originalGap = element.style.gap;
|
59 |
+
|
60 |
+
// Calculate optimal width
|
61 |
+
const optimalWidth = calculateOptimalWidth(element);
|
62 |
|
63 |
+
// Reset scroll and styles temporarily for the screenshot
|
64 |
Object.assign(element.style, {
|
65 |
+
width: `${optimalWidth}px`,
|
|
|
|
|
66 |
display: "flex",
|
67 |
flexDirection: "row",
|
68 |
gap: "32px",
|
69 |
padding: "32px",
|
70 |
+
paddingLeft: "32px !important", // Force override the dynamic padding
|
71 |
+
paddingRight: "32px !important", // Force override the dynamic padding
|
72 |
overflow: "hidden",
|
73 |
+
transition: "none", // Disable transitions during capture
|
74 |
});
|
75 |
element.scrollLeft = 0;
|
76 |
|
|
|
80 |
// Take screenshot
|
81 |
const result = await takeScreenshot(element, {
|
82 |
backgroundColor: "#242424",
|
83 |
+
width: optimalWidth,
|
84 |
height: element.scrollHeight,
|
85 |
style: {
|
86 |
transform: "none",
|
|
|
90 |
|
91 |
// Restore original styles
|
92 |
element.style.cssText = originalStyle;
|
93 |
+
element.style.width = originalWidth;
|
94 |
+
element.style.padding = originalPadding;
|
95 |
+
element.style.gap = originalGap;
|
96 |
element.scrollLeft = originalScroll;
|
97 |
|
98 |
return result;
|
client/src/layouts/ComicLayout.jsx
CHANGED
@@ -50,6 +50,22 @@ function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
|
|
50 |
return total + (segment.images?.length || 0);
|
51 |
}, 0);
|
52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
// Son d'écriture
|
54 |
const playWritingSound = useSoundEffect({
|
55 |
basePath: "/sounds/drawing-",
|
@@ -136,6 +152,7 @@ function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
|
|
136 |
}}
|
137 |
>
|
138 |
<Box
|
|
|
139 |
sx={{
|
140 |
display: "grid",
|
141 |
gridTemplateColumns: `repeat(${LAYOUTS[layout.type].gridCols}, 1fr)`,
|
@@ -189,6 +206,7 @@ function ComicPage({ layout, layoutIndex, isLastPage, preloadedImages }) {
|
|
189 |
handleImageLoad(`page-${layoutIndex}-image-${panelIndex}`)
|
190 |
}
|
191 |
imageId={`page-${layoutIndex}-image-${panelIndex}`}
|
|
|
192 |
/>
|
193 |
);
|
194 |
})}
|
|
|
50 |
return total + (segment.images?.length || 0);
|
51 |
}, 0);
|
52 |
|
53 |
+
// Sélectionner aléatoirement un panneau qui accepte le texte
|
54 |
+
const [selectedTextPanelIndex] = useState(() => {
|
55 |
+
const acceptingPanels = LAYOUTS[layout.type].panels
|
56 |
+
.slice(0, totalImages)
|
57 |
+
.map((panel, index) => ({ panel, index }))
|
58 |
+
.filter(({ panel }) => panel.acceptText);
|
59 |
+
|
60 |
+
if (acceptingPanels.length === 0) {
|
61 |
+
// Si aucun panneau n'accepte le texte, utiliser le premier panneau par défaut
|
62 |
+
return 0;
|
63 |
+
}
|
64 |
+
// Sélectionner un panneau aléatoire parmi ceux qui acceptent le texte
|
65 |
+
const randomIndex = Math.floor(Math.random() * acceptingPanels.length);
|
66 |
+
return acceptingPanels[randomIndex].index;
|
67 |
+
});
|
68 |
+
|
69 |
// Son d'écriture
|
70 |
const playWritingSound = useSoundEffect({
|
71 |
basePath: "/sounds/drawing-",
|
|
|
152 |
}}
|
153 |
>
|
154 |
<Box
|
155 |
+
data-comic-page
|
156 |
sx={{
|
157 |
display: "grid",
|
158 |
gridTemplateColumns: `repeat(${LAYOUTS[layout.type].gridCols}, 1fr)`,
|
|
|
206 |
handleImageLoad(`page-${layoutIndex}-image-${panelIndex}`)
|
207 |
}
|
208 |
imageId={`page-${layoutIndex}-image-${panelIndex}`}
|
209 |
+
showText={panelIndex === selectedTextPanelIndex}
|
210 |
/>
|
211 |
);
|
212 |
})}
|
client/src/layouts/Panel.jsx
CHANGED
@@ -43,6 +43,7 @@ export function Panel({
|
|
43 |
totalImagesInPage,
|
44 |
onImageLoad,
|
45 |
imageId,
|
|
|
46 |
}) {
|
47 |
const { regenerateImage } = useGame();
|
48 |
const [imageLoaded, setImageLoaded] = useState(
|
@@ -54,7 +55,6 @@ export function Panel({
|
|
54 |
const [isRegenerating, setIsRegenerating] = useState(false);
|
55 |
const [isSpinning, setIsSpinning] = useState(false);
|
56 |
const hasImage = segment?.images?.[panelIndex];
|
57 |
-
const isFirstPanel = panelIndex === 0;
|
58 |
const imgRef = useRef(null);
|
59 |
const imageDataRef = useRef(null);
|
60 |
const mountedRef = useRef(true);
|
@@ -234,7 +234,7 @@ export function Panel({
|
|
234 |
<RefreshIcon />
|
235 |
</IconButton>
|
236 |
</Tooltip>
|
237 |
-
{
|
238 |
<Box
|
239 |
sx={{
|
240 |
position: "absolute",
|
@@ -248,7 +248,10 @@ export function Panel({
|
|
248 |
fontWeight: 500,
|
249 |
borderRadius: "8px",
|
250 |
display: "flex",
|
|
|
251 |
alignItems: "center",
|
|
|
|
|
252 |
fontSize: { xs: "0.775rem", sm: "1rem" }, // Responsive font size
|
253 |
color: "black",
|
254 |
lineHeight: 1.1,
|
|
|
43 |
totalImagesInPage,
|
44 |
onImageLoad,
|
45 |
imageId,
|
46 |
+
showText,
|
47 |
}) {
|
48 |
const { regenerateImage } = useGame();
|
49 |
const [imageLoaded, setImageLoaded] = useState(
|
|
|
55 |
const [isRegenerating, setIsRegenerating] = useState(false);
|
56 |
const [isSpinning, setIsSpinning] = useState(false);
|
57 |
const hasImage = segment?.images?.[panelIndex];
|
|
|
58 |
const imgRef = useRef(null);
|
59 |
const imageDataRef = useRef(null);
|
60 |
const mountedRef = useRef(true);
|
|
|
234 |
<RefreshIcon />
|
235 |
</IconButton>
|
236 |
</Tooltip>
|
237 |
+
{showText && segment?.text && (
|
238 |
<Box
|
239 |
sx={{
|
240 |
position: "absolute",
|
|
|
248 |
fontWeight: 500,
|
249 |
borderRadius: "8px",
|
250 |
display: "flex",
|
251 |
+
flexDirection: "column",
|
252 |
alignItems: "center",
|
253 |
+
gap: 1,
|
254 |
+
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
255 |
fontSize: { xs: "0.775rem", sm: "1rem" }, // Responsive font size
|
256 |
color: "black",
|
257 |
lineHeight: 1.1,
|
client/src/layouts/config.js
CHANGED
@@ -21,17 +21,42 @@ export const LAYOUTS = {
|
|
21 |
gridCols: 1,
|
22 |
gridRows: 1,
|
23 |
panels: [
|
24 |
-
{
|
|
|
|
|
|
|
|
|
|
|
25 |
],
|
26 |
},
|
27 |
LAYOUT_1: {
|
28 |
gridCols: 2,
|
29 |
gridRows: 2,
|
30 |
panels: [
|
31 |
-
{
|
32 |
-
|
33 |
-
|
34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
],
|
36 |
},
|
37 |
LAYOUT_2: {
|
@@ -42,45 +67,103 @@ export const LAYOUTS = {
|
|
42 |
...PANEL_SIZES.LANDSCAPE,
|
43 |
gridColumn: GRID.TWO_THIRDS,
|
44 |
gridRow: "1",
|
|
|
45 |
}, // Large square top left
|
46 |
-
{
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
],
|
49 |
},
|
50 |
LAYOUT_3: {
|
51 |
gridCols: 3,
|
52 |
gridRows: 2,
|
53 |
panels: [
|
54 |
-
{
|
55 |
-
|
56 |
-
|
57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
58 |
],
|
59 |
},
|
60 |
LAYOUT_4: {
|
61 |
gridCols: 2,
|
62 |
gridRows: 3,
|
63 |
panels: [
|
64 |
-
{
|
|
|
|
|
|
|
|
|
|
|
65 |
{
|
66 |
...PANEL_SIZES.COLUMN,
|
67 |
gridColumn: "1",
|
68 |
gridRow: GRID.FULL_HEIGHT_FROM_2,
|
|
|
69 |
}, // Tall portrait left
|
70 |
-
{
|
71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
72 |
],
|
73 |
},
|
74 |
LAYOUT_5: {
|
75 |
gridCols: 3,
|
76 |
gridRows: 3,
|
77 |
panels: [
|
78 |
-
{
|
79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
{
|
81 |
...PANEL_SIZES.POTRAIT,
|
82 |
gridColumn: "2 / span 2",
|
83 |
gridRow: "2 / span 2",
|
|
|
84 |
}, // Large square right
|
85 |
],
|
86 |
},
|
@@ -88,8 +171,18 @@ export const LAYOUTS = {
|
|
88 |
gridCols: 1,
|
89 |
gridRows: 2,
|
90 |
panels: [
|
91 |
-
{
|
92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
93 |
],
|
94 |
},
|
95 |
};
|
@@ -103,8 +196,8 @@ export const nonRandomLayouts = Object.keys(LAYOUTS).filter(
|
|
103 |
export const LAYOUTS_BY_PANEL_COUNT = {
|
104 |
1: ["COVER"],
|
105 |
2: ["LAYOUT_7"],
|
106 |
-
3: ["LAYOUT_2"], //"LAYOUT_5"
|
107 |
-
4: ["LAYOUT_3"], //, "LAYOUT_4"
|
108 |
};
|
109 |
|
110 |
// Helper functions for layout configuration
|
|
|
21 |
gridCols: 1,
|
22 |
gridRows: 1,
|
23 |
panels: [
|
24 |
+
{
|
25 |
+
...PANEL_SIZES.COVER_SIZE,
|
26 |
+
gridColumn: "1",
|
27 |
+
gridRow: "1",
|
28 |
+
acceptText: true,
|
29 |
+
}, // Format portrait
|
30 |
],
|
31 |
},
|
32 |
LAYOUT_1: {
|
33 |
gridCols: 2,
|
34 |
gridRows: 2,
|
35 |
panels: [
|
36 |
+
{
|
37 |
+
...PANEL_SIZES.LANDSCAPE,
|
38 |
+
gridColumn: "1",
|
39 |
+
gridRow: "1",
|
40 |
+
acceptText: true,
|
41 |
+
}, // Landscape top left
|
42 |
+
{
|
43 |
+
...PANEL_SIZES.PORTRAIT,
|
44 |
+
gridColumn: "2",
|
45 |
+
gridRow: "1",
|
46 |
+
acceptText: false,
|
47 |
+
}, // Portrait top right
|
48 |
+
{
|
49 |
+
...PANEL_SIZES.LANDSCAPE,
|
50 |
+
gridColumn: "1",
|
51 |
+
gridRow: "2",
|
52 |
+
acceptText: true,
|
53 |
+
}, // Landscape middle left
|
54 |
+
{
|
55 |
+
...PANEL_SIZES.PORTRAIT,
|
56 |
+
gridColumn: "2",
|
57 |
+
gridRow: "2",
|
58 |
+
acceptText: false,
|
59 |
+
}, // Portrait right
|
60 |
],
|
61 |
},
|
62 |
LAYOUT_2: {
|
|
|
67 |
...PANEL_SIZES.LANDSCAPE,
|
68 |
gridColumn: GRID.TWO_THIRDS,
|
69 |
gridRow: "1",
|
70 |
+
acceptText: true,
|
71 |
}, // Large square top left
|
72 |
+
{
|
73 |
+
...PANEL_SIZES.PORTRAIT,
|
74 |
+
gridColumn: "3",
|
75 |
+
gridRow: "1",
|
76 |
+
acceptText: false,
|
77 |
+
}, // Portrait top right
|
78 |
+
{
|
79 |
+
...PANEL_SIZES.LANDSCAPE,
|
80 |
+
gridColumn: GRID.FULL_WIDTH,
|
81 |
+
gridRow: "2",
|
82 |
+
acceptText: false,
|
83 |
+
}, // Full width landscape bottom
|
84 |
],
|
85 |
},
|
86 |
LAYOUT_3: {
|
87 |
gridCols: 3,
|
88 |
gridRows: 2,
|
89 |
panels: [
|
90 |
+
{
|
91 |
+
...PANEL_SIZES.SQUARE,
|
92 |
+
gridColumn: GRID.TWO_THIRDS,
|
93 |
+
gridRow: "1",
|
94 |
+
acceptText: true,
|
95 |
+
}, // Wide landscape top left
|
96 |
+
{
|
97 |
+
...PANEL_SIZES.COLUMN,
|
98 |
+
gridColumn: "3",
|
99 |
+
gridRow: "1",
|
100 |
+
acceptText: false,
|
101 |
+
}, // COLUMN top right
|
102 |
+
{
|
103 |
+
...PANEL_SIZES.COLUMN,
|
104 |
+
gridColumn: "1",
|
105 |
+
gridRow: "2",
|
106 |
+
acceptText: false,
|
107 |
+
}, // COLUMN bottom left
|
108 |
+
{
|
109 |
+
...PANEL_SIZES.SQUARE,
|
110 |
+
gridColumn: "2 / span 2",
|
111 |
+
gridRow: "2",
|
112 |
+
acceptText: true,
|
113 |
+
}, // Wide landscape bottom right
|
114 |
],
|
115 |
},
|
116 |
LAYOUT_4: {
|
117 |
gridCols: 2,
|
118 |
gridRows: 3,
|
119 |
panels: [
|
120 |
+
{
|
121 |
+
...PANEL_SIZES.PANORAMIC,
|
122 |
+
gridColumn: "1 / span 2",
|
123 |
+
gridRow: "1",
|
124 |
+
acceptText: true,
|
125 |
+
}, // Wide panoramic top
|
126 |
{
|
127 |
...PANEL_SIZES.COLUMN,
|
128 |
gridColumn: "1",
|
129 |
gridRow: GRID.FULL_HEIGHT_FROM_2,
|
130 |
+
acceptText: false,
|
131 |
}, // Tall portrait left
|
132 |
+
{
|
133 |
+
...PANEL_SIZES.SQUARE,
|
134 |
+
gridColumn: "2",
|
135 |
+
gridRow: "2",
|
136 |
+
acceptText: false,
|
137 |
+
}, // Square middle right
|
138 |
+
{
|
139 |
+
...PANEL_SIZES.SQUARE,
|
140 |
+
gridColumn: "2",
|
141 |
+
gridRow: "3",
|
142 |
+
acceptText: false,
|
143 |
+
}, // Square bottom right
|
144 |
],
|
145 |
},
|
146 |
LAYOUT_5: {
|
147 |
gridCols: 3,
|
148 |
gridRows: 3,
|
149 |
panels: [
|
150 |
+
{
|
151 |
+
...PANEL_SIZES.PANORAMIC,
|
152 |
+
gridColumn: GRID.FULL_WIDTH,
|
153 |
+
gridRow: "1",
|
154 |
+
acceptText: false,
|
155 |
+
}, // Wide panoramic top
|
156 |
+
{
|
157 |
+
...PANEL_SIZES.COLUMN,
|
158 |
+
gridColumn: "1",
|
159 |
+
gridRow: "2 / span 2",
|
160 |
+
acceptText: false,
|
161 |
+
}, // Tall portrait left
|
162 |
{
|
163 |
...PANEL_SIZES.POTRAIT,
|
164 |
gridColumn: "2 / span 2",
|
165 |
gridRow: "2 / span 2",
|
166 |
+
acceptText: true,
|
167 |
}, // Large square right
|
168 |
],
|
169 |
},
|
|
|
171 |
gridCols: 1,
|
172 |
gridRows: 2,
|
173 |
panels: [
|
174 |
+
{
|
175 |
+
...PANEL_SIZES.LANDSCAPE,
|
176 |
+
gridColumn: GRID.FULL_WIDTH,
|
177 |
+
gridRow: "1",
|
178 |
+
acceptText: true,
|
179 |
+
}, // Portrait top right
|
180 |
+
{
|
181 |
+
...PANEL_SIZES.LANDSCAPE,
|
182 |
+
gridColumn: GRID.FULL_WIDTH,
|
183 |
+
gridRow: "2",
|
184 |
+
acceptText: true,
|
185 |
+
}, // Full width landscape bottom
|
186 |
],
|
187 |
},
|
188 |
};
|
|
|
196 |
export const LAYOUTS_BY_PANEL_COUNT = {
|
197 |
1: ["COVER"],
|
198 |
2: ["LAYOUT_7"],
|
199 |
+
3: ["LAYOUT_2", "LAYOUT_5"], //"LAYOUT_5"
|
200 |
+
4: ["LAYOUT_3", "LAYOUT_4"], //, "LAYOUT_4"
|
201 |
};
|
202 |
|
203 |
// Helper functions for layout configuration
|
client/src/pages/Debug.jsx
CHANGED
@@ -77,8 +77,10 @@ const Debug = () => {
|
|
77 |
const initialHistoryEntry = {
|
78 |
segment: response.story_text,
|
79 |
player_choice: null,
|
|
|
80 |
time: response.time,
|
81 |
location: response.location,
|
|
|
82 |
};
|
83 |
|
84 |
setGameState({
|
@@ -105,12 +107,14 @@ const Debug = () => {
|
|
105 |
const response = await storyApi.makeChoice(choiceIndex + 1, sessionId);
|
106 |
setCurrentStory(response);
|
107 |
|
108 |
-
// Construire l'entrée d'historique
|
109 |
const historyEntry = {
|
110 |
segment: response.story_text,
|
111 |
player_choice: currentStory.choices[choiceIndex].text,
|
|
|
112 |
time: response.time,
|
113 |
location: response.location,
|
|
|
114 |
};
|
115 |
|
116 |
setGameState((prev) => ({
|
@@ -140,6 +144,70 @@ const Debug = () => {
|
|
140 |
}
|
141 |
}, [gameState?.story_history]);
|
142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
143 |
if (error || sessionError) {
|
144 |
return (
|
145 |
<Box p={3}>
|
@@ -463,94 +531,9 @@ const Debug = () => {
|
|
463 |
}}
|
464 |
>
|
465 |
{gameState.story_history.length > 0 ? (
|
466 |
-
gameState.story_history.map((entry, idx) =>
|
467 |
-
|
468 |
-
|
469 |
-
sx={{
|
470 |
-
p: 1.5,
|
471 |
-
borderBottom: 1,
|
472 |
-
borderColor: "divider",
|
473 |
-
"&:last-child": {
|
474 |
-
borderBottom: 0,
|
475 |
-
},
|
476 |
-
backgroundColor:
|
477 |
-
idx === gameState.story_history.length - 1
|
478 |
-
? "action.hover"
|
479 |
-
: "inherit",
|
480 |
-
}}
|
481 |
-
>
|
482 |
-
<Stack spacing={1}>
|
483 |
-
{/* Story Text */}
|
484 |
-
<Box
|
485 |
-
sx={{
|
486 |
-
backgroundColor: "background.paper",
|
487 |
-
p: 1,
|
488 |
-
borderRadius: 1,
|
489 |
-
border: 1,
|
490 |
-
borderColor: "divider",
|
491 |
-
}}
|
492 |
-
>
|
493 |
-
<Typography variant="body2" color="text.primary">
|
494 |
-
{entry.segment}
|
495 |
-
</Typography>
|
496 |
-
</Box>
|
497 |
-
|
498 |
-
{/* Player Choice */}
|
499 |
-
{entry.player_choice && (
|
500 |
-
<Box
|
501 |
-
sx={{
|
502 |
-
display: "flex",
|
503 |
-
alignItems: "center",
|
504 |
-
gap: 0.5,
|
505 |
-
backgroundColor: "action.selected",
|
506 |
-
p: 1,
|
507 |
-
borderRadius: 1,
|
508 |
-
ml: 2,
|
509 |
-
}}
|
510 |
-
>
|
511 |
-
<ArrowForwardIcon
|
512 |
-
fontSize="small"
|
513 |
-
sx={{ color: "primary.main" }}
|
514 |
-
/>
|
515 |
-
<Typography
|
516 |
-
variant="caption"
|
517 |
-
sx={{
|
518 |
-
color: "text.primary",
|
519 |
-
fontWeight: "medium",
|
520 |
-
}}
|
521 |
-
>
|
522 |
-
{entry.player_choice}
|
523 |
-
</Typography>
|
524 |
-
</Box>
|
525 |
-
)}
|
526 |
-
|
527 |
-
{/* Metadata */}
|
528 |
-
<Stack
|
529 |
-
direction="row"
|
530 |
-
spacing={2}
|
531 |
-
sx={{
|
532 |
-
color: "text.secondary",
|
533 |
-
mt: 0.5,
|
534 |
-
"& > span": {
|
535 |
-
display: "flex",
|
536 |
-
alignItems: "center",
|
537 |
-
gap: 0.5,
|
538 |
-
fontSize: "0.75rem",
|
539 |
-
},
|
540 |
-
}}
|
541 |
-
>
|
542 |
-
<span>
|
543 |
-
<TimerIcon fontSize="inherit" />
|
544 |
-
{entry.time}
|
545 |
-
</span>
|
546 |
-
<span>
|
547 |
-
<LocationIcon fontSize="inherit" />
|
548 |
-
{entry.location}
|
549 |
-
</span>
|
550 |
-
</Stack>
|
551 |
-
</Stack>
|
552 |
-
</Box>
|
553 |
-
))
|
554 |
) : (
|
555 |
<Box sx={{ p: 2, textAlign: "center" }}>
|
556 |
<Typography variant="body2" color="text.secondary">
|
|
|
77 |
const initialHistoryEntry = {
|
78 |
segment: response.story_text,
|
79 |
player_choice: null,
|
80 |
+
available_choices: response.choices.map((choice) => choice.text),
|
81 |
time: response.time,
|
82 |
location: response.location,
|
83 |
+
previous_choice: response.previous_choice,
|
84 |
};
|
85 |
|
86 |
setGameState({
|
|
|
107 |
const response = await storyApi.makeChoice(choiceIndex + 1, sessionId);
|
108 |
setCurrentStory(response);
|
109 |
|
110 |
+
// Construire l'entrée d'historique
|
111 |
const historyEntry = {
|
112 |
segment: response.story_text,
|
113 |
player_choice: currentStory.choices[choiceIndex].text,
|
114 |
+
available_choices: currentStory.choices.map((choice) => choice.text),
|
115 |
time: response.time,
|
116 |
location: response.location,
|
117 |
+
previous_choice: response.previous_choice,
|
118 |
};
|
119 |
|
120 |
setGameState((prev) => ({
|
|
|
144 |
}
|
145 |
}, [gameState?.story_history]);
|
146 |
|
147 |
+
// Render history entries
|
148 |
+
const renderHistoryEntry = (entry, idx) => (
|
149 |
+
<Box
|
150 |
+
key={idx}
|
151 |
+
sx={{ mb: 2, p: 2, bgcolor: "background.paper", borderRadius: 1 }}
|
152 |
+
>
|
153 |
+
<Stack spacing={1}>
|
154 |
+
{/* Previous Choice (if any) */}
|
155 |
+
{entry.previous_choice && (
|
156 |
+
<Box
|
157 |
+
sx={{
|
158 |
+
display: "flex",
|
159 |
+
alignItems: "center",
|
160 |
+
gap: 1,
|
161 |
+
color: "text.secondary",
|
162 |
+
}}
|
163 |
+
>
|
164 |
+
<ArrowForwardIcon fontSize="small" />
|
165 |
+
<Typography variant="body2" sx={{ fontStyle: "italic" }}>
|
166 |
+
Choix précédent : {entry.previous_choice}
|
167 |
+
</Typography>
|
168 |
+
</Box>
|
169 |
+
)}
|
170 |
+
|
171 |
+
{/* Time and Location */}
|
172 |
+
<Box sx={{ display: "flex", gap: 2, color: "text.secondary" }}>
|
173 |
+
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
174 |
+
<TimerIcon fontSize="small" />
|
175 |
+
<Typography variant="body2">{entry.time}</Typography>
|
176 |
+
</Box>
|
177 |
+
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
178 |
+
<LocationIcon fontSize="small" />
|
179 |
+
<Typography variant="body2">{entry.location}</Typography>
|
180 |
+
</Box>
|
181 |
+
</Box>
|
182 |
+
|
183 |
+
{/* Story Text */}
|
184 |
+
<Typography>{entry.segment}</Typography>
|
185 |
+
|
186 |
+
{/* Available Choices */}
|
187 |
+
{entry.available_choices && entry.available_choices.length > 0 && (
|
188 |
+
<Box sx={{ mt: 1 }}>
|
189 |
+
<Typography variant="body2" color="text.secondary">
|
190 |
+
Choix disponibles :
|
191 |
+
</Typography>
|
192 |
+
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
193 |
+
{entry.available_choices.map((choice, choiceIdx) => (
|
194 |
+
<Chip
|
195 |
+
key={choiceIdx}
|
196 |
+
label={choice}
|
197 |
+
size="small"
|
198 |
+
color={choice === entry.player_choice ? "primary" : "default"}
|
199 |
+
variant={
|
200 |
+
choice === entry.player_choice ? "filled" : "outlined"
|
201 |
+
}
|
202 |
+
/>
|
203 |
+
))}
|
204 |
+
</Box>
|
205 |
+
</Box>
|
206 |
+
)}
|
207 |
+
</Stack>
|
208 |
+
</Box>
|
209 |
+
);
|
210 |
+
|
211 |
if (error || sessionError) {
|
212 |
return (
|
213 |
<Box p={3}>
|
|
|
531 |
}}
|
532 |
>
|
533 |
{gameState.story_history.length > 0 ? (
|
534 |
+
gameState.story_history.map((entry, idx) =>
|
535 |
+
renderHistoryEntry(entry, idx)
|
536 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
537 |
) : (
|
538 |
<Box sx={{ p: 2, textAlign: "center" }}>
|
539 |
<Typography variant="body2" color="text.secondary">
|
client/src/pages/Home.jsx
CHANGED
@@ -43,9 +43,9 @@ export function Home() {
|
|
43 |
justifyContent: "center",
|
44 |
minHeight: "100vh",
|
45 |
height: "100%",
|
46 |
-
width: "
|
|
|
47 |
position: "relative",
|
48 |
-
overflow: "hidden",
|
49 |
}}
|
50 |
>
|
51 |
<Typography
|
@@ -82,12 +82,11 @@ export function Home() {
|
|
82 |
</Typography>
|
83 |
|
84 |
<Typography
|
85 |
-
variant="
|
86 |
sx={{
|
87 |
zIndex: 10,
|
88 |
textAlign: "center",
|
89 |
mt: 2,
|
90 |
-
maxWidth: isMobile ? "80%" : "50%",
|
91 |
opacity: 0.8,
|
92 |
px: isMobile ? 2 : 0,
|
93 |
fontSize: "clamp(0.875rem, 2vw, 1.125rem)",
|
|
|
43 |
justifyContent: "center",
|
44 |
minHeight: "100vh",
|
45 |
height: "100%",
|
46 |
+
width: isMobile ? "80%" : "40%", // Adjust the width of the containing block
|
47 |
+
margin: "auto",
|
48 |
position: "relative",
|
|
|
49 |
}}
|
50 |
>
|
51 |
<Typography
|
|
|
82 |
</Typography>
|
83 |
|
84 |
<Typography
|
85 |
+
variant="caption"
|
86 |
sx={{
|
87 |
zIndex: 10,
|
88 |
textAlign: "center",
|
89 |
mt: 2,
|
|
|
90 |
opacity: 0.8,
|
91 |
px: isMobile ? 2 : 0,
|
92 |
fontSize: "clamp(0.875rem, 2vw, 1.125rem)",
|
client/src/pages/Tutorial.jsx
CHANGED
@@ -10,6 +10,8 @@ import { useSoundSystem } from "../contexts/SoundContext";
|
|
10 |
import { motion } from "framer-motion";
|
11 |
import { GameNavigation } from "../components/GameNavigation";
|
12 |
import { StyledText } from "../components/StyledText";
|
|
|
|
|
13 |
|
14 |
export function Tutorial() {
|
15 |
const navigate = useNavigate();
|
@@ -44,7 +46,8 @@ export function Tutorial() {
|
|
44 |
justifyContent: "center",
|
45 |
minHeight: "100vh",
|
46 |
height: "100%",
|
47 |
-
width: "
|
|
|
48 |
position: "relative",
|
49 |
overflow: "hidden",
|
50 |
}}
|
@@ -84,11 +87,73 @@ export function Tutorial() {
|
|
84 |
for each playthrough.
|
85 |
<br />
|
86 |
<br />
|
87 |
-
At
|
88 |
-
the opportunity to write the next part of the story yourself.
|
89 |
<br />
|
90 |
<br />
|
91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
92 |
</Typography>
|
93 |
|
94 |
<Button
|
|
|
10 |
import { motion } from "framer-motion";
|
11 |
import { GameNavigation } from "../components/GameNavigation";
|
12 |
import { StyledText } from "../components/StyledText";
|
13 |
+
import MouseOutlinedIcon from "@mui/icons-material/MouseOutlined";
|
14 |
+
import CreateOutlinedIcon from "@mui/icons-material/CreateOutlined";
|
15 |
|
16 |
export function Tutorial() {
|
17 |
const navigate = useNavigate();
|
|
|
46 |
justifyContent: "center",
|
47 |
minHeight: "100vh",
|
48 |
height: "100%",
|
49 |
+
width: isMobile ? "90%" : "70%",
|
50 |
+
margin: "auto",
|
51 |
position: "relative",
|
52 |
overflow: "hidden",
|
53 |
}}
|
|
|
87 |
for each playthrough.
|
88 |
<br />
|
89 |
<br />
|
90 |
+
At each step you can decide to
|
|
|
91 |
<br />
|
92 |
<br />
|
93 |
+
<Box
|
94 |
+
sx={{
|
95 |
+
display: "flex",
|
96 |
+
gap: 4,
|
97 |
+
justifyContent: "center",
|
98 |
+
mb: 2,
|
99 |
+
alignItems: "center",
|
100 |
+
}}
|
101 |
+
>
|
102 |
+
<Box
|
103 |
+
sx={{
|
104 |
+
border: "1px solid rgba(255,255,255,0.3)",
|
105 |
+
borderRadius: "8px",
|
106 |
+
p: 2,
|
107 |
+
flex: 1,
|
108 |
+
maxWidth: "200px",
|
109 |
+
display: "flex",
|
110 |
+
flexDirection: "column",
|
111 |
+
alignItems: "center",
|
112 |
+
gap: 1,
|
113 |
+
backdropFilter: "blur(20px)",
|
114 |
+
backgroundColor: "rgba(255,255,255,0.05)",
|
115 |
+
}}
|
116 |
+
>
|
117 |
+
<MouseOutlinedIcon
|
118 |
+
sx={{ fontSize: 40, color: "primary.main", mb: 1 }}
|
119 |
+
/>
|
120 |
+
<Typography variant="subtitle1" sx={{ color: "primary.main" }}>
|
121 |
+
Make a choice
|
122 |
+
</Typography>
|
123 |
+
</Box>
|
124 |
+
<Typography
|
125 |
+
variant="h6"
|
126 |
+
sx={{
|
127 |
+
color: "rgba(255,255,255,0.5)",
|
128 |
+
fontWeight: "bold",
|
129 |
+
}}
|
130 |
+
>
|
131 |
+
OR
|
132 |
+
</Typography>
|
133 |
+
<Box
|
134 |
+
sx={{
|
135 |
+
border: "1px solid rgba(255,255,255,0.3)",
|
136 |
+
borderRadius: "8px",
|
137 |
+
p: 2,
|
138 |
+
flex: 1,
|
139 |
+
maxWidth: "200px",
|
140 |
+
display: "flex",
|
141 |
+
flexDirection: "column",
|
142 |
+
alignItems: "center",
|
143 |
+
gap: 1,
|
144 |
+
backdropFilter: "blur(20px)",
|
145 |
+
backgroundColor: "rgba(255,255,255,0.05)",
|
146 |
+
}}
|
147 |
+
>
|
148 |
+
<CreateOutlinedIcon
|
149 |
+
sx={{ fontSize: 40, color: "primary.main", mb: 1 }}
|
150 |
+
/>
|
151 |
+
<Typography variant="subtitle1" sx={{ color: "primary.main" }}>
|
152 |
+
Write your own
|
153 |
+
</Typography>
|
154 |
+
</Box>
|
155 |
+
</Box>
|
156 |
+
Until the end of the game
|
157 |
</Typography>
|
158 |
|
159 |
<Button
|
server/api/models.py
CHANGED
@@ -62,6 +62,7 @@ class UniverseResponse(BaseModel):
|
|
62 |
|
63 |
# Complete story response combining all parts - preserved for API compatibility
|
64 |
class StoryResponse(BaseModel):
|
|
|
65 |
story_text: str = Field(description="The story text. No more than 15 words THIS IS MANDATORY. Never mention story beat directly. ")
|
66 |
choices: List[Choice]
|
67 |
raw_choices: List[str] = Field(description="Raw choice texts from LLM before conversion to Choice objects")
|
|
|
62 |
|
63 |
# Complete story response combining all parts - preserved for API compatibility
|
64 |
class StoryResponse(BaseModel):
|
65 |
+
previous_choice: str = Field(description="The previous choice made by the player")
|
66 |
story_text: str = Field(description="The story text. No more than 15 words THIS IS MANDATORY. Never mention story beat directly. ")
|
67 |
choices: List[Choice]
|
68 |
raw_choices: List[str] = Field(description="Raw choice texts from LLM before conversion to Choice objects")
|
server/api/routes/chat.py
CHANGED
@@ -57,49 +57,45 @@ def get_chat_router(session_manager: SessionManager, story_generator):
|
|
57 |
# Pour les choix personnalisés, on les traite immédiatement
|
58 |
if chat_message.message == "custom_choice" and chat_message.custom_text:
|
59 |
previous_choice = chat_message.custom_text
|
60 |
-
# On
|
61 |
-
|
62 |
-
f"You decide to: {chat_message.custom_text}",
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
)
|
|
|
68 |
else:
|
69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
70 |
|
71 |
# Generate story segment
|
72 |
-
|
73 |
session_id=x_session_id,
|
74 |
game_state=game_state,
|
75 |
previous_choice=previous_choice
|
76 |
)
|
77 |
|
78 |
-
# Add segment to history
|
79 |
-
game_state.add_to_history(
|
80 |
-
llm_response.story_text,
|
81 |
-
previous_choice,
|
82 |
-
llm_response.image_prompts,
|
83 |
-
llm_response.time,
|
84 |
-
llm_response.location
|
85 |
-
)
|
86 |
-
|
87 |
# Pour la première étape, on ne garde qu'un seul prompt d'image
|
88 |
-
if game_state.story_beat == 0 and len(
|
89 |
-
|
90 |
-
|
91 |
-
# Prepare response
|
92 |
-
response = StoryResponse(
|
93 |
-
story_text=llm_response.story_text,
|
94 |
-
choices=llm_response.choices,
|
95 |
-
raw_choices=llm_response.raw_choices,
|
96 |
-
time=llm_response.time,
|
97 |
-
location=llm_response.location,
|
98 |
-
is_first_step=game_state.story_beat == 0,
|
99 |
-
image_prompts=llm_response.image_prompts,
|
100 |
-
is_death=llm_response.is_death,
|
101 |
-
is_victory=llm_response.is_victory
|
102 |
-
)
|
103 |
|
104 |
# Increment story beat
|
105 |
game_state.story_beat += 1
|
|
|
57 |
# Pour les choix personnalisés, on les traite immédiatement
|
58 |
if chat_message.message == "custom_choice" and chat_message.custom_text:
|
59 |
previous_choice = chat_message.custom_text
|
60 |
+
# On crée un StoryResponse pour le choix personnalisé
|
61 |
+
custom_choice_response = StoryResponse(
|
62 |
+
story_text=f"You decide to: {chat_message.custom_text}",
|
63 |
+
choices=[
|
64 |
+
Choice(id=1, text="Continue..."), # Choix fictif pour validation
|
65 |
+
Choice(id=2, text="Continue...")
|
66 |
+
],
|
67 |
+
raw_choices=["Continue...", "Continue..."],
|
68 |
+
time=game_state.current_time,
|
69 |
+
location=game_state.current_location,
|
70 |
+
image_prompts=["Character making a custom choice"], # Prompt fictif pour validation
|
71 |
+
is_first_step=False,
|
72 |
+
is_death=False,
|
73 |
+
is_victory=False,
|
74 |
+
previous_choice=previous_choice
|
75 |
)
|
76 |
+
game_state.add_to_history(custom_choice_response)
|
77 |
else:
|
78 |
+
# Si un choix a été fait, récupérer le texte du choix à partir de l'historique
|
79 |
+
if chat_message.choice_id and len(game_state.story_history) > 0:
|
80 |
+
last_story = game_state.story_history[-1]
|
81 |
+
choice_index = chat_message.choice_id - 1
|
82 |
+
if 0 <= choice_index < len(last_story.choices):
|
83 |
+
previous_choice = last_story.choices[choice_index].text
|
84 |
+
else:
|
85 |
+
previous_choice = "none"
|
86 |
+
else:
|
87 |
+
previous_choice = "none"
|
88 |
|
89 |
# Generate story segment
|
90 |
+
response = await story_generator.generate_story_segment(
|
91 |
session_id=x_session_id,
|
92 |
game_state=game_state,
|
93 |
previous_choice=previous_choice
|
94 |
)
|
95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
96 |
# Pour la première étape, on ne garde qu'un seul prompt d'image
|
97 |
+
if game_state.story_beat == 0 and len(response.image_prompts) > 1:
|
98 |
+
response.image_prompts = [response.image_prompts[0]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
99 |
|
100 |
# Increment story beat
|
101 |
game_state.story_beat += 1
|
server/core/game_state.py
CHANGED
@@ -1,10 +1,11 @@
|
|
1 |
from core.constants import GameConfig
|
2 |
from typing import List
|
|
|
3 |
|
4 |
class GameState:
|
5 |
def __init__(self):
|
6 |
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
7 |
-
self.story_history = []
|
8 |
self.current_time = GameConfig.STARTING_TIME
|
9 |
self.current_location = GameConfig.STARTING_LOCATION
|
10 |
# Universe information
|
@@ -50,27 +51,42 @@ class GameState:
|
|
50 |
])
|
51 |
|
52 |
def format_history(self) -> str:
|
53 |
-
"""Format story history for the prompt.
|
|
|
54 |
if not self.story_history:
|
55 |
return ""
|
56 |
|
|
|
|
|
|
|
57 |
segments = []
|
58 |
-
for
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
63 |
|
64 |
return "\n\n---\n\n".join(segments)
|
65 |
|
66 |
-
def add_to_history(self,
|
67 |
-
"""Add a
|
68 |
-
self.story_history.append(
|
69 |
-
|
70 |
-
|
71 |
-
"time": time,
|
72 |
-
"location": location,
|
73 |
-
"image_prompts": image_prompts
|
74 |
-
})
|
75 |
-
self.current_time = time
|
76 |
-
self.current_location = location
|
|
|
1 |
from core.constants import GameConfig
|
2 |
from typing import List
|
3 |
+
from api.models import StoryResponse
|
4 |
|
5 |
class GameState:
|
6 |
def __init__(self):
|
7 |
self.story_beat = GameConfig.STORY_BEAT_INTRO
|
8 |
+
self.story_history: List[StoryResponse] = []
|
9 |
self.current_time = GameConfig.STARTING_TIME
|
10 |
self.current_location = GameConfig.STARTING_LOCATION
|
11 |
# Universe information
|
|
|
51 |
])
|
52 |
|
53 |
def format_history(self) -> str:
|
54 |
+
"""Format story history for the prompt.
|
55 |
+
Returns only the last 4 segments of the story (or less if not available)."""
|
56 |
if not self.story_history:
|
57 |
return ""
|
58 |
|
59 |
+
# Ne prendre que les 3 derniers segments
|
60 |
+
last_segments = self.story_history[-4:] if len(self.story_history) > 4 else self.story_history
|
61 |
+
|
62 |
segments = []
|
63 |
+
for story_response in last_segments:
|
64 |
+
# Commencer par le choix précédent s'il existe
|
65 |
+
segment_parts = []
|
66 |
+
if story_response.previous_choice and story_response.previous_choice != "none":
|
67 |
+
segment_parts.append(f"[Previous choice: {story_response.previous_choice}]")
|
68 |
+
|
69 |
+
# Ajouter le texte de l'histoire
|
70 |
+
segment_parts.append(story_response.story_text)
|
71 |
+
|
72 |
+
# Ajouter les choix disponibles s'ils existent
|
73 |
+
if story_response.choices:
|
74 |
+
choices_text = "\nAvailable choices were:"
|
75 |
+
for choice in story_response.choices:
|
76 |
+
choices_text += f"\n- {choice.text}"
|
77 |
+
segment_parts.append(choices_text)
|
78 |
+
|
79 |
+
# Joindre toutes les parties avec des sauts de ligne
|
80 |
+
segments.append("\n".join(segment_parts))
|
81 |
+
|
82 |
+
# Ajouter une indication si on a tronqué l'historique
|
83 |
+
if len(self.story_history) > 4:
|
84 |
+
segments.insert(0, f"[...{len(self.story_history) - 4} earlier segments omitted...]")
|
85 |
|
86 |
return "\n\n---\n\n".join(segments)
|
87 |
|
88 |
+
def add_to_history(self, story_response: StoryResponse):
|
89 |
+
"""Add a story response to history."""
|
90 |
+
self.story_history.append(story_response)
|
91 |
+
self.current_time = story_response.time
|
92 |
+
self.current_location = story_response.location
|
|
|
|
|
|
|
|
|
|
|
|
server/core/generators/image_prompt_generator.py
CHANGED
@@ -2,7 +2,7 @@ from typing import List
|
|
2 |
from pydantic import BaseModel, Field
|
3 |
from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate
|
4 |
import json
|
5 |
-
|
6 |
from core.generators.base_generator import BaseGenerator
|
7 |
|
8 |
class ImagePromptResponse(BaseModel):
|
@@ -67,25 +67,21 @@ class ImagePromptGenerator(BaseGenerator):
|
|
67 |
"[shot type] [scene description]"
|
68 |
|
69 |
EXAMPLES:
|
70 |
-
- "
|
71 |
-
- "
|
72 |
-
- "
|
73 |
-
- "
|
74 |
-
- "
|
75 |
-
- "
|
76 |
-
- "high angle shot of a
|
77 |
-
- "
|
78 |
-
- "
|
79 |
-
- "
|
80 |
-
- "
|
81 |
-
- "
|
82 |
-
- "
|
83 |
-
- "
|
84 |
-
- "
|
85 |
-
- "close-up of a hand gripping a sword hilt, ready for battle"
|
86 |
-
- "wide shot of a bustling port with ships coming and going, seagulls circling above"
|
87 |
-
- "high angle shot of a chessboard mid-game, pieces scattered in strategic positions"
|
88 |
-
- "medium shot of two characters in a heated argument, tension visible in their expressions"
|
89 |
|
90 |
Always maintain consistency with {self.hero_name}'s appearance and the style.
|
91 |
|
@@ -122,11 +118,13 @@ Story text: {story_text}
|
|
122 |
Generate panel descriptions that capture the key moments of this scene.
|
123 |
do not have panels that look alike, each successive panel must be different,
|
124 |
and explain the story like a storyboard.
|
|
|
125 |
|
126 |
Dont put the hero name every time.
|
127 |
-
|
128 |
|
129 |
{is_end}
|
|
|
130 |
"""
|
131 |
|
132 |
return ChatPromptTemplate(
|
@@ -230,7 +228,14 @@ Exactly between 1 and 4 panels. (mostly 2 or 3)
|
|
230 |
ImagePromptResponse containing the generated and formatted image prompts
|
231 |
"""
|
232 |
|
233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
234 |
if is_death:
|
235 |
is_end = f"This is the death of {self.hero_name}. just one panel, MANDATORY."
|
236 |
elif is_victory:
|
@@ -243,6 +248,7 @@ Exactly between 1 and 4 panels. (mostly 2 or 3)
|
|
243 |
is_death=is_death,
|
244 |
is_victory=is_victory,
|
245 |
is_end=is_end,
|
|
|
246 |
)
|
247 |
|
248 |
# Format each prompt with metadata
|
|
|
2 |
from pydantic import BaseModel, Field
|
3 |
from langchain.prompts import ChatPromptTemplate, SystemMessagePromptTemplate, HumanMessagePromptTemplate
|
4 |
import json
|
5 |
+
import random
|
6 |
from core.generators.base_generator import BaseGenerator
|
7 |
|
8 |
class ImagePromptResponse(BaseModel):
|
|
|
67 |
"[shot type] [scene description]"
|
68 |
|
69 |
EXAMPLES:
|
70 |
+
- "medium shot of a bustling marketplace, vibrant colors and lively chatter"
|
71 |
+
- "close-up of a mysterious figure's eyes, reflecting a hidden agenda"
|
72 |
+
- "wide shot of a serene lake at dawn, mist rising from the water"
|
73 |
+
- "Dutch angle of a character sprinting through a narrow alley, urgency in every step"
|
74 |
+
- "over shoulder shot of a child peering into a forbidden book, curiosity in their eyes"
|
75 |
+
- "low angle shot of a towering skyscraper, clouds swirling around its peak"
|
76 |
+
- "high angle shot of a chessboard mid-game, pieces poised for a decisive move"
|
77 |
+
- "close-up of a hand reaching for a glowing orb, anticipation in the air"
|
78 |
+
- "wide shot of a desert landscape, a lone figure trudging through the sand"
|
79 |
+
- "medium shot of a character standing in a rain-soaked street, determination etched on their face"
|
80 |
+
- "Dutch angle of a clock tower striking midnight, shadows stretching across the square"
|
81 |
+
- "over shoulder shot of a detective examining a crime scene, clues scattered around"
|
82 |
+
- "close-up of a flower blooming in a crack in the pavement, symbolizing hope"
|
83 |
+
- "wide shot of a stormy sea, waves crashing against a rocky shore"
|
84 |
+
- "medium shot of a group of friends laughing around a campfire, warmth in their expressions"
|
|
|
|
|
|
|
|
|
85 |
|
86 |
Always maintain consistency with {self.hero_name}'s appearance and the style.
|
87 |
|
|
|
118 |
Generate panel descriptions that capture the key moments of this scene.
|
119 |
do not have panels that look alike, each successive panel must be different,
|
120 |
and explain the story like a storyboard.
|
121 |
+
SHOW, DONT TELL. DESCRIBE THE PANELS, be specific, put names on things.
|
122 |
|
123 |
Dont put the hero name every time.
|
124 |
+
{how_many_panels} panels
|
125 |
|
126 |
{is_end}
|
127 |
+
|
128 |
"""
|
129 |
|
130 |
return ChatPromptTemplate(
|
|
|
228 |
ImagePromptResponse containing the generated and formatted image prompts
|
229 |
"""
|
230 |
|
231 |
+
how_many_panels = 2
|
232 |
+
# Générer un nombre aléatoire de panneaux entre 1 et 4
|
233 |
+
if is_death or is_victory:
|
234 |
+
how_many_panels = 1
|
235 |
+
else:
|
236 |
+
how_many_panels = random.choices([1, 2, 3, 4], weights=[0.05, 0.3, 0.4, 0.25], k=1)[0]
|
237 |
+
|
238 |
+
is_end=""
|
239 |
if is_death:
|
240 |
is_end = f"This is the death of {self.hero_name}. just one panel, MANDATORY."
|
241 |
elif is_victory:
|
|
|
248 |
is_death=is_death,
|
249 |
is_victory=is_victory,
|
250 |
is_end=is_end,
|
251 |
+
how_many_panels=how_many_panels,
|
252 |
)
|
253 |
|
254 |
# Format each prompt with metadata
|
server/core/generators/metadata_generator.py
CHANGED
@@ -35,44 +35,50 @@ class MetadataGenerator(BaseGenerator):
|
|
35 |
You must return a JSON object with the following format:
|
36 |
{{{{
|
37 |
"is_death": false, # Set to true for death scenes
|
38 |
-
"is_victory": false # Set to true for victory scenes
|
39 |
-
"choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
|
40 |
"time": "HH:MM",
|
41 |
"location": "Location",
|
|
|
42 |
}}}}
|
43 |
"""
|
44 |
|
45 |
human_template = """
|
46 |
|
47 |
-
History:
|
48 |
-
{story_history}
|
49 |
-
|
50 |
-
Current story segment:
|
51 |
-
{story_text}
|
52 |
-
|
53 |
-
- Current time: {current_time}
|
54 |
-
- Current location: {current_location}
|
55 |
|
56 |
|
57 |
FOR CHOICES : NEVER propose to go back to the previous location or go back to the portal. NEVER.
|
58 |
Dont be obvious. NEVER use "approach the ...", its too slow to be a choice.
|
59 |
|
60 |
-
{is_end}
|
61 |
|
62 |
You can be original in your choices, but dont be too far from the story.
|
63 |
Dont be too cliché. The choices should be realistically different.
|
64 |
The choices should be the direct continuation of the story.
|
65 |
The choices should be the direct continuation of the story.
|
66 |
The choices should be the direct continuation of the story.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
|
68 |
- Each choice MUST be NO MORE than 6 words - this is a HARD limit
|
69 |
You must return a JSON object with the following format:
|
70 |
{{{{
|
71 |
"is_death": false, # Set to true for death scenes
|
72 |
-
"is_victory": false # Set to true for victory scenes
|
73 |
-
"choices": ["Choice 1", "Choice 2"], # ALWAYS exactly two choices, each max 6 words
|
74 |
"time": "HH:MM",
|
75 |
"location": "Location name",
|
|
|
76 |
}}}}
|
77 |
|
78 |
"""
|
@@ -87,45 +93,76 @@ You must return a JSON object with the following format:
|
|
87 |
|
88 |
def _validate_choices(self, choices) -> bool:
|
89 |
"""Valide que les choix respectent les règles."""
|
90 |
-
if not isinstance(choices, list)
|
91 |
return False
|
92 |
|
|
|
|
|
|
|
|
|
|
|
93 |
for choice in choices:
|
94 |
-
|
95 |
-
word_count = len(choice.split())
|
96 |
-
if word_count > 6:
|
97 |
return False
|
98 |
-
|
99 |
-
# Vérifier que le choix n'est pas vide
|
100 |
-
if not choice.strip():
|
101 |
return False
|
102 |
-
|
103 |
-
# Vérifier que les choix ne contiennent pas de mots interdits
|
104 |
-
forbidden_words = ["back", "return", "portal"]
|
105 |
-
if any(word.lower() in choice.lower() for word in forbidden_words):
|
106 |
return False
|
107 |
-
|
108 |
-
# Vérifier que les choix sont différents
|
109 |
-
if choices[0].lower() == choices[1].lower():
|
110 |
-
return False
|
111 |
|
112 |
return True
|
113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
114 |
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, story_history: str = "") -> StoryMetadataResponse:
|
115 |
"""Surcharge de generate pour inclure le error_feedback par défaut."""
|
116 |
|
117 |
is_end = "This IS the end of the story." if story_beat == turn_before_end else ""
|
118 |
retry_count = 0
|
119 |
last_error = None
|
|
|
120 |
|
121 |
while retry_count < self.max_retries:
|
122 |
try:
|
|
|
|
|
|
|
123 |
response = await super().generate(
|
124 |
story_text=story_text,
|
125 |
current_time=current_time,
|
126 |
current_location=current_location,
|
127 |
story_beat=story_beat,
|
128 |
-
error_feedback=
|
129 |
is_end=is_end,
|
130 |
turn_before_end=turn_before_end,
|
131 |
is_winning_story=is_winning_story,
|
@@ -133,19 +170,16 @@ You must return a JSON object with the following format:
|
|
133 |
)
|
134 |
|
135 |
print(f"[MetadataGenerator] Raw response before validation (attempt {retry_count + 1}):", response)
|
136 |
-
|
137 |
-
print(f"[MetadataGenerator] Time:", response.time)
|
138 |
-
print(f"[MetadataGenerator] Location:", response.location)
|
139 |
-
|
140 |
# Valider les choix
|
141 |
if self._validate_choices(response.choices):
|
142 |
print("[MetadataGenerator] Validation successful!")
|
143 |
return response
|
144 |
|
145 |
print(f"[MetadataGenerator] Validation failed for choices:", response.choices)
|
146 |
-
|
|
|
147 |
retry_count += 1
|
148 |
-
error_feedback = f"Previous choices were invalid. Remember: EXACTLY 2 choices, MAX 6 words each, must be different and relevant. Last attempt: {response.choices}"
|
149 |
continue
|
150 |
|
151 |
except Exception as e:
|
@@ -155,7 +189,6 @@ You must return a JSON object with the following format:
|
|
155 |
if retry_count >= self.max_retries:
|
156 |
print(f"[MetadataGenerator] Failed to generate valid metadata after {self.max_retries} attempts. Last error: {str(e)}")
|
157 |
raise e
|
158 |
-
error_feedback = f"Error in previous attempt: {str(e)}. Please try again with valid format."
|
159 |
continue
|
160 |
|
161 |
# Si on arrive ici, c'est qu'on a épuisé toutes les tentatives
|
@@ -178,14 +211,16 @@ You must return a JSON object with the following format:
|
|
178 |
print("[MetadataGenerator] First cleaning failed:", str(e1))
|
179 |
# Deuxième tentative : supprimer les commentaires et les espaces superflus
|
180 |
import re
|
181 |
-
# Supprimer les commentaires
|
182 |
cleaned_content = re.sub(r'#.*$', '', cleaned_content, flags=re.MULTILINE)
|
183 |
-
# Supprimer les espaces superflus
|
184 |
cleaned_content = re.sub(r'\s+', ' ', cleaned_content)
|
185 |
print("[MetadataGenerator] Second cleaning attempt:", cleaned_content)
|
186 |
-
|
187 |
-
|
188 |
-
|
|
|
|
|
|
|
|
|
189 |
# Vérifier que les choix sont valides selon les règles
|
190 |
choices = data.get('choices', [])
|
191 |
print("[MetadataGenerator] Extracted choices:", choices)
|
@@ -208,4 +243,4 @@ You must return a JSON object with the following format:
|
|
208 |
except Exception as e:
|
209 |
print("[MetadataGenerator] Final error:", str(e))
|
210 |
print("[MetadataGenerator] Failed to parse response content")
|
211 |
-
raise ValueError(
|
|
|
35 |
You must return a JSON object with the following format:
|
36 |
{{{{
|
37 |
"is_death": false, # Set to true for death scenes
|
38 |
+
"is_victory": false, # Set to true for victory scenes
|
|
|
39 |
"time": "HH:MM",
|
40 |
"location": "Location",
|
41 |
+
"choices": ["Choice 1", "Choice 2"] # ALWAYS exactly two choices, each max 6 words
|
42 |
}}}}
|
43 |
"""
|
44 |
|
45 |
human_template = """
|
46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
|
48 |
|
49 |
FOR CHOICES : NEVER propose to go back to the previous location or go back to the portal. NEVER.
|
50 |
Dont be obvious. NEVER use "approach the ...", its too slow to be a choice.
|
51 |
|
|
|
52 |
|
53 |
You can be original in your choices, but dont be too far from the story.
|
54 |
Dont be too cliché. The choices should be realistically different.
|
55 |
The choices should be the direct continuation of the story.
|
56 |
The choices should be the direct continuation of the story.
|
57 |
The choices should be the direct continuation of the story.
|
58 |
+
The choice not have to be the most obvious one. or even the most logical one.
|
59 |
+
|
60 |
+
History:
|
61 |
+
{story_history}
|
62 |
+
|
63 |
+
Current story segment:
|
64 |
+
{story_text}
|
65 |
+
|
66 |
+
- Current time: {current_time}
|
67 |
+
- Current location: {current_location}
|
68 |
+
|
69 |
+
{is_end}
|
70 |
+
|
71 |
+
The choice not have to be the most obvious one. or even the most logical one.
|
72 |
+
It MUST have a relation to the context of the story but it can be a choice that doesn't make sense.
|
73 |
|
74 |
- Each choice MUST be NO MORE than 6 words - this is a HARD limit
|
75 |
You must return a JSON object with the following format:
|
76 |
{{{{
|
77 |
"is_death": false, # Set to true for death scenes
|
78 |
+
"is_victory": false, # Set to true for victory scenes
|
|
|
79 |
"time": "HH:MM",
|
80 |
"location": "Location name",
|
81 |
+
"choices": ["Choice 1", "Choice 2"] # ALWAYS exactly two choices, each max 6 words
|
82 |
}}}}
|
83 |
|
84 |
"""
|
|
|
93 |
|
94 |
def _validate_choices(self, choices) -> bool:
|
95 |
"""Valide que les choix respectent les règles."""
|
96 |
+
if not isinstance(choices, list):
|
97 |
return False
|
98 |
|
99 |
+
if len(choices) != 2:
|
100 |
+
return False
|
101 |
+
|
102 |
+
# Vérifier que les choix sont différents et pas trop longs
|
103 |
+
seen_choices = set()
|
104 |
for choice in choices:
|
105 |
+
if not isinstance(choice, str):
|
|
|
|
|
106 |
return False
|
107 |
+
if len(choice.split()) > 6: # Max 6 mots
|
|
|
|
|
108 |
return False
|
109 |
+
if choice.lower() in seen_choices:
|
|
|
|
|
|
|
110 |
return False
|
111 |
+
seen_choices.add(choice.lower())
|
|
|
|
|
|
|
112 |
|
113 |
return True
|
114 |
|
115 |
+
def _get_error_feedback(self, error, response=None) -> str:
|
116 |
+
"""Génère un feedback spécifique basé sur le type d'erreur."""
|
117 |
+
if isinstance(error, json.JSONDecodeError):
|
118 |
+
return "Your response must be a valid JSON object. Please ensure proper JSON formatting."
|
119 |
+
|
120 |
+
if "choices" in str(error).lower():
|
121 |
+
choices = response.choices if response and hasattr(response, 'choices') else []
|
122 |
+
issues = []
|
123 |
+
|
124 |
+
if not isinstance(choices, list):
|
125 |
+
return "The 'choices' field must be a list containing exactly 2 choices."
|
126 |
+
|
127 |
+
if len(choices) != 2:
|
128 |
+
issues.append(f"Found {len(choices)} choices, need exactly 2")
|
129 |
+
|
130 |
+
seen = set()
|
131 |
+
for choice in choices:
|
132 |
+
if not isinstance(choice, str):
|
133 |
+
issues.append("All choices must be strings")
|
134 |
+
elif len(choice.split()) > 6:
|
135 |
+
issues.append(f"Choice '{choice}' is too long (max 6 words)")
|
136 |
+
elif choice.lower() in seen:
|
137 |
+
issues.append(f"Choice '{choice}' is duplicated")
|
138 |
+
seen.add(choice.lower())
|
139 |
+
|
140 |
+
return "Choice validation failed: " + ", ".join(issues)
|
141 |
+
|
142 |
+
if "missing" in str(error).lower():
|
143 |
+
return "Missing required fields in response. Please include: is_death, is_victory, choices, time, location"
|
144 |
+
|
145 |
+
return str(error)
|
146 |
+
|
147 |
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, story_history: str = "") -> StoryMetadataResponse:
|
148 |
"""Surcharge de generate pour inclure le error_feedback par défaut."""
|
149 |
|
150 |
is_end = "This IS the end of the story." if story_beat == turn_before_end else ""
|
151 |
retry_count = 0
|
152 |
last_error = None
|
153 |
+
last_response = None
|
154 |
|
155 |
while retry_count < self.max_retries:
|
156 |
try:
|
157 |
+
# Si on a un feedback d'erreur précédent, l'utiliser
|
158 |
+
current_feedback = self._get_error_feedback(last_error, last_response) if last_error else error_feedback
|
159 |
+
|
160 |
response = await super().generate(
|
161 |
story_text=story_text,
|
162 |
current_time=current_time,
|
163 |
current_location=current_location,
|
164 |
story_beat=story_beat,
|
165 |
+
error_feedback=current_feedback,
|
166 |
is_end=is_end,
|
167 |
turn_before_end=turn_before_end,
|
168 |
is_winning_story=is_winning_story,
|
|
|
170 |
)
|
171 |
|
172 |
print(f"[MetadataGenerator] Raw response before validation (attempt {retry_count + 1}):", response)
|
173 |
+
|
|
|
|
|
|
|
174 |
# Valider les choix
|
175 |
if self._validate_choices(response.choices):
|
176 |
print("[MetadataGenerator] Validation successful!")
|
177 |
return response
|
178 |
|
179 |
print(f"[MetadataGenerator] Validation failed for choices:", response.choices)
|
180 |
+
last_response = response
|
181 |
+
last_error = ValueError("Invalid choices format")
|
182 |
retry_count += 1
|
|
|
183 |
continue
|
184 |
|
185 |
except Exception as e:
|
|
|
189 |
if retry_count >= self.max_retries:
|
190 |
print(f"[MetadataGenerator] Failed to generate valid metadata after {self.max_retries} attempts. Last error: {str(e)}")
|
191 |
raise e
|
|
|
192 |
continue
|
193 |
|
194 |
# Si on arrive ici, c'est qu'on a épuisé toutes les tentatives
|
|
|
211 |
print("[MetadataGenerator] First cleaning failed:", str(e1))
|
212 |
# Deuxième tentative : supprimer les commentaires et les espaces superflus
|
213 |
import re
|
|
|
214 |
cleaned_content = re.sub(r'#.*$', '', cleaned_content, flags=re.MULTILINE)
|
|
|
215 |
cleaned_content = re.sub(r'\s+', ' ', cleaned_content)
|
216 |
print("[MetadataGenerator] Second cleaning attempt:", cleaned_content)
|
217 |
+
try:
|
218 |
+
data = json.loads(cleaned_content)
|
219 |
+
print("[MetadataGenerator] Successfully parsed JSON after second cleaning")
|
220 |
+
except json.JSONDecodeError as e2:
|
221 |
+
print("[MetadataGenerator] Second cleaning failed:", str(e2))
|
222 |
+
raise ValueError("Failed to parse JSON after multiple cleaning attempts")
|
223 |
+
|
224 |
# Vérifier que les choix sont valides selon les règles
|
225 |
choices = data.get('choices', [])
|
226 |
print("[MetadataGenerator] Extracted choices:", choices)
|
|
|
243 |
except Exception as e:
|
244 |
print("[MetadataGenerator] Final error:", str(e))
|
245 |
print("[MetadataGenerator] Failed to parse response content")
|
246 |
+
raise ValueError(str(e))
|
server/core/generators/story_segment_generator.py
CHANGED
@@ -60,7 +60,7 @@ Base Story:
|
|
60 |
|
61 |
Your task is to generate the next segment of the story, following these rules:
|
62 |
1. Keep the story consistent with the universe parameters
|
63 |
-
2. Each segment must
|
64 |
3. Never repeat previous descriptions or situations
|
65 |
4. Keep segments concise and impactful
|
66 |
|
@@ -73,12 +73,14 @@ Hero Description: {self.hero_desc}
|
|
73 |
Story history:
|
74 |
{story_history}
|
75 |
|
76 |
-
|
77 |
Never describes game variables.
|
78 |
|
79 |
IT MUST BE THE DIRECT CONTINUATION OF THE CURRENT STORY.
|
80 |
You MUST mention the previous situation and what is happening now with the new choice.
|
81 |
Never propose choices or options. Never describe the game variables.
|
|
|
|
|
|
|
82 |
LIMIT: 15 words.
|
83 |
"""
|
84 |
return ChatPromptTemplate(
|
@@ -155,7 +157,9 @@ LIMIT: 15 words.
|
|
155 |
is_death = True if is_end and is_winning_story else False
|
156 |
is_victory = True if is_end and not is_winning_story else False
|
157 |
|
158 |
-
what_to_represent =
|
|
|
|
|
159 |
|
160 |
# Si c'est un choix personnalisé, on l'utilise comme contexte pour générer la suite
|
161 |
if previous_choice and not previous_choice.startswith("Choice "):
|
@@ -163,13 +167,13 @@ LIMIT: 15 words.
|
|
163 |
Based on the player's custom choice: "{previous_choice}"
|
164 |
|
165 |
Write a story segment that:
|
166 |
-
1.
|
167 |
-
2.
|
168 |
-
3.
|
169 |
-
4.
|
170 |
-
5.
|
171 |
-
|
172 |
-
|
173 |
"""
|
174 |
|
175 |
# Créer les messages
|
|
|
60 |
|
61 |
Your task is to generate the next segment of the story, following these rules:
|
62 |
1. Keep the story consistent with the universe parameters
|
63 |
+
2. Each segment must go forward in the story
|
64 |
3. Never repeat previous descriptions or situations
|
65 |
4. Keep segments concise and impactful
|
66 |
|
|
|
73 |
Story history:
|
74 |
{story_history}
|
75 |
|
|
|
76 |
Never describes game variables.
|
77 |
|
78 |
IT MUST BE THE DIRECT CONTINUATION OF THE CURRENT STORY.
|
79 |
You MUST mention the previous situation and what is happening now with the new choice.
|
80 |
Never propose choices or options. Never describe the game variables.
|
81 |
+
The world is a dangerous place. The hero is in GREAT danger. he has great risk to die.
|
82 |
+
If you fail a big battle, the hero is dead.
|
83 |
+
|
84 |
LIMIT: 15 words.
|
85 |
"""
|
86 |
return ChatPromptTemplate(
|
|
|
157 |
is_death = True if is_end and is_winning_story else False
|
158 |
is_victory = True if is_end and not is_winning_story else False
|
159 |
|
160 |
+
what_to_represent = ""
|
161 |
+
# what_to_represent = self._get_what_to_represent(story_beat, is_death, is_victory)
|
162 |
+
#
|
163 |
|
164 |
# Si c'est un choix personnalisé, on l'utilise comme contexte pour générer la suite
|
165 |
if previous_choice and not previous_choice.startswith("Choice "):
|
|
|
167 |
Based on the player's custom choice: "{previous_choice}"
|
168 |
|
169 |
Write a story segment that:
|
170 |
+
1. Respects all previous rules about length and style
|
171 |
+
2. Ff you find a path to go in a special place, you have to travel there.
|
172 |
+
3. Directly follows and incorporates the player's choice
|
173 |
+
4. Maintains consistency with the universe and story
|
174 |
+
5. Naturally integrates the custom elements while staying true to the plot
|
175 |
+
|
176 |
+
MANDATORY : Start with a direct reaction to the player's choice, Show immediate consequences of their action. Then go forward in the story.
|
177 |
"""
|
178 |
|
179 |
# Créer les messages
|
server/core/generators/universe_generator.py
CHANGED
@@ -24,7 +24,7 @@ class UniverseGenerator(BaseGenerator):
|
|
24 |
- Genre: {genre}
|
25 |
- Historical epoch: {epoch}
|
26 |
|
27 |
-
Describe the first segment of the story. in
|
28 |
"""
|
29 |
|
30 |
return ChatPromptTemplate(
|
|
|
24 |
- Genre: {genre}
|
25 |
- Historical epoch: {epoch}
|
26 |
|
27 |
+
Describe the first segment of the story. in 30 words. Where is the main character, what is he doing? HE has to do something banal. You have to describe the first action.
|
28 |
"""
|
29 |
|
30 |
return ChatPromptTemplate(
|
server/core/story_generator.py
CHANGED
@@ -49,7 +49,7 @@ class StoryGenerator:
|
|
49 |
artist = style["references"][0]["artist"]
|
50 |
|
51 |
# Create a detailed artist style string
|
52 |
-
artist_style = f"{
|
53 |
|
54 |
# Always create a new ImagePromptGenerator for each session with the correct artist and hero
|
55 |
self.image_prompt_generator = ImagePromptGenerator(
|
@@ -128,7 +128,6 @@ class StoryGenerator:
|
|
128 |
is_winning_story=self.is_winning_story,
|
129 |
story_history=game_state.format_history()
|
130 |
)
|
131 |
-
# print(f"Generated metadata_response: {metadata_response}")
|
132 |
|
133 |
# Generate image prompts
|
134 |
prompts_response = await self.image_prompt_generator.generate(
|
@@ -140,7 +139,6 @@ class StoryGenerator:
|
|
140 |
turn_before_end=self.turn_before_end,
|
141 |
is_winning_story=self.is_winning_story
|
142 |
)
|
143 |
-
# print(f"Generated image prompts: {prompts_response}")
|
144 |
|
145 |
# Create choices
|
146 |
choices = [
|
@@ -151,16 +149,21 @@ class StoryGenerator:
|
|
151 |
response = StoryResponse(
|
152 |
story_text=story_text,
|
153 |
choices=choices,
|
|
|
154 |
time=metadata_response.time,
|
155 |
location=metadata_response.location,
|
156 |
-
raw_choices=metadata_response.choices,
|
157 |
image_prompts=prompts_response.image_prompts,
|
158 |
is_first_step=(game_state.story_beat == GameConfig.STORY_BEAT_INTRO),
|
159 |
is_death=metadata_response.is_death,
|
160 |
-
is_victory=metadata_response.is_victory
|
|
|
161 |
)
|
162 |
|
163 |
-
|
|
|
|
|
|
|
|
|
164 |
except Exception as e:
|
165 |
print(f"Unexpected error in generate_story_segment: {str(e)}")
|
166 |
raise
|
|
|
49 |
artist = style["references"][0]["artist"]
|
50 |
|
51 |
# Create a detailed artist style string
|
52 |
+
artist_style = f"{style['name']}, {genre} in {epoch}"
|
53 |
|
54 |
# Always create a new ImagePromptGenerator for each session with the correct artist and hero
|
55 |
self.image_prompt_generator = ImagePromptGenerator(
|
|
|
128 |
is_winning_story=self.is_winning_story,
|
129 |
story_history=game_state.format_history()
|
130 |
)
|
|
|
131 |
|
132 |
# Generate image prompts
|
133 |
prompts_response = await self.image_prompt_generator.generate(
|
|
|
139 |
turn_before_end=self.turn_before_end,
|
140 |
is_winning_story=self.is_winning_story
|
141 |
)
|
|
|
142 |
|
143 |
# Create choices
|
144 |
choices = [
|
|
|
149 |
response = StoryResponse(
|
150 |
story_text=story_text,
|
151 |
choices=choices,
|
152 |
+
raw_choices=metadata_response.choices,
|
153 |
time=metadata_response.time,
|
154 |
location=metadata_response.location,
|
|
|
155 |
image_prompts=prompts_response.image_prompts,
|
156 |
is_first_step=(game_state.story_beat == GameConfig.STORY_BEAT_INTRO),
|
157 |
is_death=metadata_response.is_death,
|
158 |
+
is_victory=metadata_response.is_victory,
|
159 |
+
previous_choice=previous_choice
|
160 |
)
|
161 |
|
162 |
+
# Add the response to game state history
|
163 |
+
game_state.add_to_history(response)
|
164 |
+
|
165 |
+
return response
|
166 |
+
|
167 |
except Exception as e:
|
168 |
print(f"Unexpected error in generate_story_segment: {str(e)}")
|
169 |
raise
|
server/core/styles/universe_styles.json
CHANGED
@@ -43,7 +43,6 @@
|
|
43 |
"Fantasy",
|
44 |
"Adventure",
|
45 |
"Mystery",
|
46 |
-
"Romance",
|
47 |
"Horror",
|
48 |
"Drama"
|
49 |
],
|
|
|
43 |
"Fantasy",
|
44 |
"Adventure",
|
45 |
"Mystery",
|
|
|
46 |
"Horror",
|
47 |
"Drama"
|
48 |
],
|
server/scripts/test_game.py
CHANGED
@@ -41,26 +41,31 @@ def print_universe_info(style: str, genre: str, epoch: str, base_story: str):
|
|
41 |
print_separator("*")
|
42 |
|
43 |
def print_story_step(step_number, story_text, image_prompts, generation_time: float, story_history: str = None, show_context: bool = False, model_name: str = None, is_death: bool = False, is_victory: bool = False):
|
44 |
-
|
45 |
-
print(
|
46 |
-
print(f"
|
47 |
-
print(
|
48 |
-
print(f"🏆 Victory: {is_victory}")
|
49 |
|
50 |
if show_context and story_history:
|
51 |
-
|
52 |
-
print("
|
53 |
print(story_history)
|
|
|
54 |
|
55 |
-
|
56 |
-
print("📜 STORY:")
|
57 |
print(story_text)
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
64 |
|
65 |
async def play_game(show_context: bool = False, auto_mode: bool = False, max_turns: int = 15):
|
66 |
# Initialize components
|
@@ -133,10 +138,10 @@ async def play_game(show_context: bool = False, auto_mode: bool = False, max_tur
|
|
133 |
story_history = ""
|
134 |
if game_state.story_history:
|
135 |
segments = []
|
136 |
-
for
|
137 |
-
segment =
|
138 |
-
time_location = f"[{
|
139 |
-
image_descriptions = "\nVisual panels:\n" + "\n".join(f"- {prompt}" for prompt in
|
140 |
segments.append(f"{time_location}\n{segment}{image_descriptions}")
|
141 |
|
142 |
story_history = "\n\n---\n\n".join(segments)
|
@@ -199,13 +204,7 @@ async def play_game(show_context: bool = False, auto_mode: bool = False, max_tur
|
|
199 |
|
200 |
# Update game state
|
201 |
game_state.story_beat += 1
|
202 |
-
|
203 |
-
response.story_text,
|
204 |
-
f"Choice {last_choice}",
|
205 |
-
response.image_prompts,
|
206 |
-
response.time,
|
207 |
-
response.location
|
208 |
-
)
|
209 |
|
210 |
else:
|
211 |
print("\n❌ Error: Invalid number of choices received from server")
|
|
|
41 |
print_separator("*")
|
42 |
|
43 |
def print_story_step(step_number, story_text, image_prompts, generation_time: float, story_history: str = None, show_context: bool = False, model_name: str = None, is_death: bool = False, is_victory: bool = False):
|
44 |
+
"""Print a story step with formatting."""
|
45 |
+
print("\n" + "="*80)
|
46 |
+
print(f"Step {step_number}")
|
47 |
+
print("-"*80)
|
|
|
48 |
|
49 |
if show_context and story_history:
|
50 |
+
print("\nContext:")
|
51 |
+
print("-"*20)
|
52 |
print(story_history)
|
53 |
+
print("-"*20 + "\n")
|
54 |
|
55 |
+
print(f"Story ({generation_time:.2f}s):")
|
|
|
56 |
print(story_text)
|
57 |
+
|
58 |
+
if image_prompts:
|
59 |
+
print("\nImage Prompts:")
|
60 |
+
for i, prompt in enumerate(image_prompts, 1):
|
61 |
+
print(f"{i}. {prompt}")
|
62 |
+
|
63 |
+
if is_death:
|
64 |
+
print("\n💀 GAME OVER - You died!")
|
65 |
+
elif is_victory:
|
66 |
+
print("\n🏆 VICTORY - You won!")
|
67 |
+
|
68 |
+
print("="*80)
|
69 |
|
70 |
async def play_game(show_context: bool = False, auto_mode: bool = False, max_turns: int = 15):
|
71 |
# Initialize components
|
|
|
138 |
story_history = ""
|
139 |
if game_state.story_history:
|
140 |
segments = []
|
141 |
+
for story_response in game_state.story_history:
|
142 |
+
segment = story_response.story_text
|
143 |
+
time_location = f"[{story_response.time} - {story_response.location}]"
|
144 |
+
image_descriptions = "\nVisual panels:\n" + "\n".join(f"- {prompt}" for prompt in story_response.image_prompts)
|
145 |
segments.append(f"{time_location}\n{segment}{image_descriptions}")
|
146 |
|
147 |
story_history = "\n\n---\n\n".join(segments)
|
|
|
204 |
|
205 |
# Update game state
|
206 |
game_state.story_beat += 1
|
207 |
+
# Le StoryResponse est déjà ajouté à l'historique dans generate_story_segment
|
|
|
|
|
|
|
|
|
|
|
|
|
208 |
|
209 |
else:
|
210 |
print("\n❌ Error: Invalid number of choices received from server")
|
server/services/flux_client.py
CHANGED
@@ -17,7 +17,7 @@ class FluxClient:
|
|
17 |
prompt: str,
|
18 |
width: int,
|
19 |
height: int,
|
20 |
-
num_inference_steps: int =
|
21 |
guidance_scale: float = 9.0) -> Optional[bytes]:
|
22 |
"""Génère une image à partir d'un prompt."""
|
23 |
try:
|
|
|
17 |
prompt: str,
|
18 |
width: int,
|
19 |
height: int,
|
20 |
+
num_inference_steps: int = 5,
|
21 |
guidance_scale: float = 9.0) -> Optional[bytes]:
|
22 |
"""Génère une image à partir d'un prompt."""
|
23 |
try:
|
server/services/mistral_client.py
CHANGED
@@ -30,6 +30,22 @@ logger = logging.getLogger(__name__)
|
|
30 |
#
|
31 |
# Pricing: https://docs.mistral.ai/platform/pricing/
|
32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
class MistralClient:
|
34 |
def __init__(self, api_key: str, model_name: str = "mistral-large-latest", max_tokens: int = 1000):
|
35 |
logger.info(f"Initializing MistralClient with model: {model_name}, max_tokens: {max_tokens}")
|
@@ -48,6 +64,8 @@ class MistralClient:
|
|
48 |
self.last_call_time = 0
|
49 |
self.min_delay = 1 # 1 seconde minimum entre les appels
|
50 |
self.max_retries = 5
|
|
|
|
|
51 |
|
52 |
async def _wait_for_rate_limit(self):
|
53 |
"""Attend le temps nécessaire pour respecter le rate limit."""
|
@@ -61,6 +79,19 @@ class MistralClient:
|
|
61 |
|
62 |
self.last_call_time = asyncio.get_event_loop().time()
|
63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
64 |
async def _generate_with_retry(
|
65 |
self,
|
66 |
messages: list[BaseMessage],
|
@@ -68,77 +99,68 @@ class MistralClient:
|
|
68 |
custom_parser: Optional[Callable[[str], T]] = None,
|
69 |
error_feedback: str = None
|
70 |
) -> T | str:
|
71 |
-
"""
|
72 |
-
Génère une réponse avec retry et parsing structuré optionnel.
|
73 |
-
|
74 |
-
Args:
|
75 |
-
messages: Liste des messages pour le modèle
|
76 |
-
response_model: Classe Pydantic pour parser la réponse
|
77 |
-
custom_parser: Fonction de parsing personnalisée
|
78 |
-
error_feedback: Feedback d'erreur à ajouter au prompt en cas de retry
|
79 |
-
"""
|
80 |
retry_count = 0
|
81 |
last_error = None
|
82 |
|
83 |
while retry_count < self.max_retries:
|
84 |
try:
|
85 |
-
# Log attempt
|
86 |
logger.info(f"Attempt {retry_count + 1}/{self.max_retries}")
|
87 |
|
88 |
-
# Ajouter le feedback d'erreur si présent
|
89 |
current_messages = messages.copy()
|
90 |
if error_feedback and retry_count > 0:
|
91 |
-
|
92 |
-
|
|
|
|
|
|
|
|
|
93 |
|
94 |
-
# Log request details
|
95 |
-
logger.debug("Request details:")
|
96 |
-
for msg in current_messages:
|
97 |
-
logger.debug(f"- {msg.type}: {msg.content[:100]}...")
|
98 |
-
|
99 |
-
# Générer la réponse
|
100 |
await self._wait_for_rate_limit()
|
101 |
try:
|
102 |
response = await self.model.ainvoke(current_messages)
|
103 |
content = response.content
|
104 |
logger.debug(f"Raw response: {content[:100]}...")
|
105 |
except Exception as api_error:
|
106 |
-
|
107 |
-
|
108 |
-
|
|
|
|
|
109 |
raise
|
110 |
-
|
111 |
# Si pas de parsing requis, retourner le contenu brut
|
112 |
if not response_model and not custom_parser:
|
113 |
return content
|
114 |
-
|
115 |
# Parser la réponse
|
116 |
-
if custom_parser:
|
117 |
-
return custom_parser(content)
|
118 |
-
|
119 |
-
# Essayer de parser avec le modèle Pydantic
|
120 |
try:
|
|
|
|
|
|
|
|
|
121 |
data = json.loads(content)
|
122 |
return response_model(**data)
|
123 |
except json.JSONDecodeError as e:
|
124 |
-
last_error = f"Invalid JSON format: {str(e)}"
|
125 |
-
logger.error(f"JSON parsing error: {
|
126 |
-
raise
|
127 |
except Exception as e:
|
128 |
-
last_error = str(e)
|
129 |
-
logger.error(f"
|
130 |
-
raise
|
131 |
-
|
132 |
-
except
|
133 |
logger.error(f"Error on attempt {retry_count + 1}/{self.max_retries}: {str(e)}")
|
|
|
134 |
retry_count += 1
|
135 |
if retry_count < self.max_retries:
|
136 |
-
wait_time =
|
137 |
logger.info(f"Waiting {wait_time} seconds before retry...")
|
138 |
await asyncio.sleep(wait_time)
|
139 |
continue
|
140 |
-
|
141 |
-
|
|
|
142 |
|
143 |
async def generate(self, messages: list[BaseMessage], response_model: Optional[Type[T]] = None, custom_parser: Optional[Callable[[str], T]] = None) -> T | str:
|
144 |
"""Génère une réponse à partir d'une liste de messages avec parsing optionnel."""
|
|
|
30 |
#
|
31 |
# Pricing: https://docs.mistral.ai/platform/pricing/
|
32 |
|
33 |
+
class MistralAPIError(Exception):
|
34 |
+
"""Base class for Mistral API errors"""
|
35 |
+
pass
|
36 |
+
|
37 |
+
class MistralRateLimitError(MistralAPIError):
|
38 |
+
"""Raised when hitting rate limits"""
|
39 |
+
pass
|
40 |
+
|
41 |
+
class MistralParsingError(MistralAPIError):
|
42 |
+
"""Raised when response parsing fails"""
|
43 |
+
pass
|
44 |
+
|
45 |
+
class MistralValidationError(MistralAPIError):
|
46 |
+
"""Raised when response validation fails"""
|
47 |
+
pass
|
48 |
+
|
49 |
class MistralClient:
|
50 |
def __init__(self, api_key: str, model_name: str = "mistral-large-latest", max_tokens: int = 1000):
|
51 |
logger.info(f"Initializing MistralClient with model: {model_name}, max_tokens: {max_tokens}")
|
|
|
64 |
self.last_call_time = 0
|
65 |
self.min_delay = 1 # 1 seconde minimum entre les appels
|
66 |
self.max_retries = 5
|
67 |
+
self.backoff_factor = 2 # For exponential backoff
|
68 |
+
self.max_backoff = 30 # Maximum backoff time in seconds
|
69 |
|
70 |
async def _wait_for_rate_limit(self):
|
71 |
"""Attend le temps nécessaire pour respecter le rate limit."""
|
|
|
79 |
|
80 |
self.last_call_time = asyncio.get_event_loop().time()
|
81 |
|
82 |
+
async def _handle_api_error(self, error: Exception, retry_count: int) -> float:
|
83 |
+
"""Handle API errors and return wait time for retry"""
|
84 |
+
wait_time = min(self.backoff_factor ** retry_count, self.max_backoff)
|
85 |
+
|
86 |
+
if "rate limit" in str(error).lower():
|
87 |
+
logger.warning(f"Rate limit hit, waiting {wait_time}s before retry")
|
88 |
+
raise MistralRateLimitError(str(error))
|
89 |
+
elif "403" in str(error):
|
90 |
+
logger.error("Authentication error - invalid API key or quota exceeded")
|
91 |
+
raise MistralAPIError("Authentication failed")
|
92 |
+
|
93 |
+
return wait_time
|
94 |
+
|
95 |
async def _generate_with_retry(
|
96 |
self,
|
97 |
messages: list[BaseMessage],
|
|
|
99 |
custom_parser: Optional[Callable[[str], T]] = None,
|
100 |
error_feedback: str = None
|
101 |
) -> T | str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
102 |
retry_count = 0
|
103 |
last_error = None
|
104 |
|
105 |
while retry_count < self.max_retries:
|
106 |
try:
|
|
|
107 |
logger.info(f"Attempt {retry_count + 1}/{self.max_retries}")
|
108 |
|
|
|
109 |
current_messages = messages.copy()
|
110 |
if error_feedback and retry_count > 0:
|
111 |
+
if isinstance(last_error, MistralParsingError):
|
112 |
+
# For parsing errors, add structured format reminder
|
113 |
+
current_messages.append(HumanMessage(content="Please ensure your response is in valid JSON format."))
|
114 |
+
elif isinstance(last_error, MistralValidationError):
|
115 |
+
# For validation errors, add the specific feedback
|
116 |
+
current_messages.append(HumanMessage(content=f"Previous error: {error_feedback}. Please try again."))
|
117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
118 |
await self._wait_for_rate_limit()
|
119 |
try:
|
120 |
response = await self.model.ainvoke(current_messages)
|
121 |
content = response.content
|
122 |
logger.debug(f"Raw response: {content[:100]}...")
|
123 |
except Exception as api_error:
|
124 |
+
wait_time = await self._handle_api_error(api_error, retry_count)
|
125 |
+
retry_count += 1
|
126 |
+
if retry_count < self.max_retries:
|
127 |
+
await asyncio.sleep(wait_time)
|
128 |
+
continue
|
129 |
raise
|
130 |
+
|
131 |
# Si pas de parsing requis, retourner le contenu brut
|
132 |
if not response_model and not custom_parser:
|
133 |
return content
|
134 |
+
|
135 |
# Parser la réponse
|
|
|
|
|
|
|
|
|
136 |
try:
|
137 |
+
if custom_parser:
|
138 |
+
return custom_parser(content)
|
139 |
+
|
140 |
+
# Essayer de parser avec le modèle Pydantic
|
141 |
data = json.loads(content)
|
142 |
return response_model(**data)
|
143 |
except json.JSONDecodeError as e:
|
144 |
+
last_error = MistralParsingError(f"Invalid JSON format: {str(e)}")
|
145 |
+
logger.error(f"JSON parsing error: {str(e)}")
|
146 |
+
raise last_error
|
147 |
except Exception as e:
|
148 |
+
last_error = MistralValidationError(str(e))
|
149 |
+
logger.error(f"Validation error: {str(e)}")
|
150 |
+
raise last_error
|
151 |
+
|
152 |
+
except (MistralParsingError, MistralValidationError) as e:
|
153 |
logger.error(f"Error on attempt {retry_count + 1}/{self.max_retries}: {str(e)}")
|
154 |
+
last_error = e
|
155 |
retry_count += 1
|
156 |
if retry_count < self.max_retries:
|
157 |
+
wait_time = min(self.backoff_factor ** retry_count, self.max_backoff)
|
158 |
logger.info(f"Waiting {wait_time} seconds before retry...")
|
159 |
await asyncio.sleep(wait_time)
|
160 |
continue
|
161 |
+
|
162 |
+
logger.error(f"Failed after {self.max_retries} attempts. Last error: {str(last_error)}")
|
163 |
+
raise Exception(f"Failed after {self.max_retries} attempts. Last error: {str(last_error)}")
|
164 |
|
165 |
async def generate(self, messages: list[BaseMessage], response_model: Optional[Type[T]] = None, custom_parser: Optional[Callable[[str], T]] = None) -> T | str:
|
166 |
"""Génère une réponse à partir d'une liste de messages avec parsing optionnel."""
|