kikikita commited on
Commit
cfd75dc
·
1 Parent(s): 4083692

feat: Implement interactive narrative game framework

Browse files
.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ GEMINI_API_KEY=KEY
.gitignore ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+
110
+ # pdm
111
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112
+ #pdm.lock
113
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114
+ # in version control.
115
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116
+ .pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121
+ __pypackages__/
122
+
123
+ # Celery stuff
124
+ celerybeat-schedule
125
+ celerybeat.pid
126
+
127
+ # SageMath parsed files
128
+ *.sage.py
129
+
130
+ # Environments
131
+ .env
132
+ .venv
133
+ env/
134
+ venv/
135
+ ENV/
136
+ env.bak/
137
+ venv.bak/
138
+
139
+ # Spyder project settings
140
+ .spyderproject
141
+ .spyproject
142
+
143
+ # Rope project settings
144
+ .ropeproject
145
+
146
+ # mkdocs documentation
147
+ /site
148
+
149
+ # mypy
150
+ .mypy_cache/
151
+ .dmypy.json
152
+ dmypy.json
153
+
154
+ # Pyre type checker
155
+ .pyre/
156
+
157
+ # pytype static type analyzer
158
+ .pytype/
159
+
160
+ # Cython debug symbols
161
+ cython_debug/
162
+
163
+ # PyCharm
164
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
167
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168
+ #.idea/
169
+
170
+ # Ruff stuff:
171
+ .ruff_cache/
172
+
173
+ # PyPI configuration file
174
+ .pypirc
175
+ versions/
176
+
177
+ .gradio
178
+ generated/
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==5.32.0
2
+ google-genai==1.18.0
3
+ Pillow==10.1.0
4
+ requests==2.31.0
5
+ python-dotenv==1.0.0
6
+ asyncio
7
+ aiohttp==3.9.1
8
+ pygame==2.5.2
9
+ numpy
10
+ langchain==0.3.17
11
+ langchain-core==0.3.58
12
+ langchain-community==0.3.16
13
+ langchain-google-genai==2.1.4
14
+ pydantic-core==2.23.4
15
+ pydantic-settings==2.7.1
16
+ pydantic==2.9.2
src/agent/image_agent.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Literal, Optional
3
+ from agent.llm import create_light_llm
4
+ from langchain_core.messages import SystemMessage, HumanMessage
5
+ import logging
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ IMAGE_GENERATION_SYSTEM_PROMPT = """You are an AI agent for a visual novel game. Your role is to process an incoming scene description and determine if the visual scene needs to change. If it does, you will generate a new `scene_description`. This `scene_description` MUST BE a highly detailed image prompt, specifically engineered for an AI image generation model, and it MUST adhere to the strict first-person perspective detailed below.
11
+
12
+ **Your Core Tasks & Output Structure:**
13
+ Your output MUST be a `ChangeScene` object. You need to:
14
+ 1. **Determine Change Type:** Decide if the scene requires a "change_completely", "modify", or "no_change" and set this in the `change_scene` field of the output object.
15
+ 2. **Generate FPS Image Prompt:** If your decision is "change_completely" or "modify", you MUST then generate the image prompt and place it in the `scene_description` field of the output object. If "no_change", this field can be null or empty.
16
+
17
+ **Mandatory: First-Person Perspective (FPS) for Image Prompts**
18
+ The image prompt you generate for the `scene_description` field MUST strictly describe the scene from a first-person perspective (FPS), as if the player is looking directly through the character's eyes.
19
+ * **Viewpoint:** All descriptions must be from the character's eye level, looking forward or as indicated by the scene.
20
+ * **Character Visibility:** The scene must be depicted strictly as if looking through the character's eyes. NO part of the character's own body (e.g., hands, arms, feet, clothing on them) should be visible or described in the prompt. The view is purely what is external to the character.
21
+ * **Immersion:** Focus on what the character directly sees and perceives in their immediate environment. Use phrasing that reflects this, for example: "I see...", "Before me lies...", "Looking through the grimy window...", "The corridor stretches out in front of me."
22
+
23
+ **Guidelines for Crafting the FPS Image Prompt (for `scene_description` field):**
24
+ When generating the image prompt, ensure it's detailed and considers the following aspects, all from the character's first-person viewpoint:
25
+
26
+ 1. **Subject & Focus (as seen by the character):**
27
+ * What is the primary subject or point of interest directly in the character's view?
28
+ * Describe any other characters visible to the POV character: their appearance (from the character's perspective), clothing, expressions, posture, and actions.
29
+ * Detail key objects, items, or environmental elements the character is interacting with or observing.
30
+
31
+ 2. **Setting & Environment (from the character's perspective):**
32
+ * Describe the immediate surroundings as the character would see them.
33
+ * Time of day and weather conditions as perceived by the character.
34
+ * Specific architectural or natural features visible in the character's field of view.
35
+
36
+ 3. **Art Style & Medium:**
37
+ * Specify the desired visual style (e.g., photorealistic, anime, manga, watercolor, oil painting, pixel art, 3D render, concept art, comic book).
38
+ * Mention any specific artist influences if relevant (e.g., "in the style of Studio Ghibli").
39
+
40
+ 4. **Composition & Framing (from the character's viewpoint):**
41
+ * How is the scene framed from the character's eyes? (e.g., "looking straight ahead at a door," "view through a sniper scope," "gazing up at a tall tower").
42
+ * Describe the arrangement of elements as perceived by the character. Avoid terms like "medium shot" or "wide shot" unless they can be rephrased from an FPS view (e.g., "a wide vista opens up before me").
43
+
44
+ 5. **Lighting & Atmosphere (as perceived by the character):**
45
+ * Describe lighting conditions (e.g., "bright sunlight streams through the window in front of me," "only the dim glow of my flashlight illuminates the passage ahead," "neon signs reflect off the wet street I'm looking at").
46
+ * What is the overall mood or atmosphere from the character's perspective? (e.g., "a tense silence hangs in the air as I look down the dark hallway," "a sense of peace as I gaze at the sunset over the mountains").
47
+
48
+ 6. **Color Palette:**
49
+ * Specify dominant colors or a color scheme relevant to what the character sees.
50
+
51
+ 7. **Details & Keywords:**
52
+ * Include crucial details from the input scene description that the character would notice.
53
+ * Use descriptive adjectives and strong keywords.
54
+
55
+ **Example for the `scene_description` field (the FPS image prompt):**
56
+ "FPS view. Through the cockpit window of a futuristic hovercar, a sprawling neon-lit cyberpunk city stretches out under a stormy, rain-lashed sky. Rain streaks across the glass. The hum of the engine is palpable. Photorealistic, Blade Runner style. Cool blue and vibrant pink neon palette."
57
+ """
58
+
59
+
60
+ class ChangeScene(BaseModel):
61
+ change_scene: Literal["change_completely", "modify", "no_change"] = Field(
62
+ description="Whether the scene should be completely changed, just modified or not changed at all"
63
+ )
64
+ scene_description: Optional[str] = None
65
+
66
+
67
+ image_prompt_generator_llm = create_light_llm(0.1).with_structured_output(ChangeScene)
68
+
69
+ async def generate_image_prompt(scene_description: str, request_id: str) -> ChangeScene:
70
+ """
71
+ Generates a detailed image prompt string based on a scene description.
72
+ This prompt is intended for use with an AI image generation model.
73
+ """
74
+ logger.info(f"Generating image prompt for the current scene: {request_id}")
75
+ response = await image_prompt_generator_llm.ainvoke(
76
+ [
77
+ SystemMessage(content=IMAGE_GENERATION_SYSTEM_PROMPT),
78
+ HumanMessage(content=scene_description),
79
+ ]
80
+ )
81
+ logger.info(f"Image prompt generated: {request_id}")
82
+ return response
src/agent/llm.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utility functions for working with the language model."""
2
+
3
+ import logging
4
+ from langchain_google_genai import ChatGoogleGenerativeAI
5
+
6
+ from config import settings
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ _API_KEYS: list[str] = []
11
+ _current_key_idx = 0
12
+ MODEL_NAME = "gemini-2.5-flash-preview-05-20"
13
+
14
+
15
+ def _get_api_key() -> str:
16
+ """Return an API key using round-robin selection."""
17
+ global _API_KEYS, _current_key_idx
18
+
19
+ if not _API_KEYS:
20
+ keys_str = settings.gemini_api_key.get_secret_value()
21
+ if keys_str:
22
+ _API_KEYS = [k.strip() for k in keys_str.split(",") if k.strip()]
23
+ if not _API_KEYS:
24
+ msg = "Google API keys are not configured or invalid"
25
+ logger.error(msg)
26
+ raise ValueError(msg)
27
+
28
+ key = _API_KEYS[_current_key_idx]
29
+ _current_key_idx = (_current_key_idx + 1) % len(_API_KEYS)
30
+ logger.debug("Using Google API key index %s", _current_key_idx)
31
+ return key
32
+
33
+
34
+ def create_llm(
35
+ temperature: float = settings.temperature,
36
+ top_p: float = settings.top_p,
37
+ ) -> ChatGoogleGenerativeAI:
38
+ """Create a standard LLM instance."""
39
+ return ChatGoogleGenerativeAI(
40
+ model=MODEL_NAME,
41
+ google_api_key=_get_api_key(),
42
+ temperature=temperature,
43
+ top_p=top_p,
44
+ thinking_budget=1024,
45
+ )
46
+
47
+
48
+ def create_light_llm(temperature: float = settings.temperature, top_p: float = settings.top_p):
49
+ return ChatGoogleGenerativeAI(
50
+ model="gemini-2.0-flash",
51
+ google_api_key=_get_api_key(),
52
+ temperature=temperature,
53
+ top_p=top_p
54
+ )
55
+
56
+
57
+ def create_precise_llm() -> ChatGoogleGenerativeAI:
58
+ """Return an LLM tuned for deterministic output."""
59
+ return create_llm(temperature=0, top_p=1)
src/agent/llm_agent.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from agent.llm import create_llm
2
+ from pydantic import BaseModel, Field
3
+ from typing import List
4
+ import logging
5
+ from agent.image_agent import ChangeScene
6
+ import asyncio
7
+ from agent.music_agent import generate_music_prompt
8
+ from agent.image_agent import generate_image_prompt
9
+ import uuid
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class PlayerOption(BaseModel):
15
+ option_description: str = Field(
16
+ description="The description of the option, Examples: [Change location] Go to the forest; [Say] Hello!"
17
+ )
18
+
19
+
20
+ class LLMOutput(BaseModel):
21
+ game_message: str = Field(
22
+ description="The message to the player, Example: You entered the forest, and you see unknown scary creatures. What do you do?"
23
+ )
24
+ player_options: List[PlayerOption] = Field(
25
+ description="The list of up to 3 options for the player to choose from."
26
+ )
27
+
28
+
29
+ class MultiAgentResponse(BaseModel):
30
+ game_message: str = Field(
31
+ description="The message to the player, Example: You entered the forest, and you see unknown scary creatures. What do you do?"
32
+ )
33
+ player_options: List[PlayerOption] = Field(
34
+ description="The list of up to 3 options for the player to choose from."
35
+ )
36
+ music_prompt: str = Field(description="The prompt for the music generation model.")
37
+ change_scene: ChangeScene = Field(description="The change to the scene.")
38
+
39
+ llm = create_llm().with_structured_output(MultiAgentResponse)
40
+
41
+
42
+ async def process_user_input(input: str) -> MultiAgentResponse:
43
+ """
44
+ Process user input and update the state.
45
+ """
46
+ request_id = str(uuid.uuid4())
47
+ logger.info(f"LLM input received: {request_id}")
48
+
49
+ response: LLMOutput = await llm.ainvoke(input)
50
+
51
+ # return response
52
+ current_state = f"""{input}
53
+
54
+ Game reaction: {response.game_message}
55
+ Player options: {response.player_options}
56
+ """
57
+
58
+ music_prompt_task = generate_music_prompt(current_state, request_id)
59
+
60
+ change_scene_task = generate_image_prompt(current_state, request_id)
61
+
62
+ music_prompt, change_scene = await asyncio.gather(music_prompt_task, change_scene_task)
63
+
64
+ multi_agent_response = MultiAgentResponse(
65
+ game_message=response.game_message,
66
+ player_options=response.player_options,
67
+ music_prompt=music_prompt,
68
+ change_scene=change_scene,
69
+ )
70
+
71
+ logger.info(f"LLM responded: {request_id}")
72
+
73
+ return multi_agent_response
src/agent/llm_graph.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph setup for the interactive fiction agent."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, Optional
6
+ import asyncio
7
+ from langgraph.graph import END, StateGraph
8
+ from agent.image_agent import generate_image_prompt
9
+
10
+ from agent.tools import (
11
+ check_ending,
12
+ generate_scene,
13
+ generate_scene_image,
14
+ generate_story_frame,
15
+ update_state_with_choice,
16
+ )
17
+ from agent.state import get_user_state
18
+ from audio.audio_generator import change_music_tone
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class GraphState:
24
+ """Mutable state passed between graph nodes."""
25
+
26
+ user_hash: Optional[str] = None
27
+ step: Optional[str] = None
28
+ setting: Optional[str] = None
29
+ character: Optional[Dict[str, Any]] = None
30
+ genre: Optional[str] = None
31
+ choice_text: Optional[str] = None
32
+ scene: Optional[Dict[str, Any]] = None
33
+ ending: Optional[Dict[str, Any]] = None
34
+
35
+
36
+ async def node_entry(state: GraphState) -> GraphState:
37
+ logger.debug("[Graph] entry state: %s", state)
38
+ return state
39
+
40
+
41
+ def route_step(state: GraphState) -> str:
42
+ if state.step == "start":
43
+ return "init_game"
44
+ if state.step == "choose":
45
+ return "player_step"
46
+ logger.warning("route_step received unknown step '%s'", state.step)
47
+ return "init_game"
48
+
49
+
50
+ async def node_init_game(state: GraphState) -> GraphState:
51
+ logger.debug("[Graph] node_init_game state: %s", state)
52
+ await generate_story_frame.ainvoke(
53
+ {
54
+ "user_hash": state.user_hash,
55
+ "setting": state.setting,
56
+ "character": state.character,
57
+ "genre": state.genre,
58
+ }
59
+ )
60
+ first_scene = await generate_scene.ainvoke(
61
+ {"user_hash": state.user_hash, "last_choice": "start"}
62
+ )
63
+ change_scene = await generate_image_prompt(first_scene["description"], state.user_hash)
64
+ logger.info(f"Change scene: {change_scene}")
65
+ await generate_scene_image.ainvoke(
66
+ {
67
+ "user_hash": state.user_hash,
68
+ "scene_id": first_scene["scene_id"],
69
+ "change_scene": change_scene,
70
+ }
71
+ )
72
+ state.scene = first_scene
73
+ return state
74
+
75
+
76
+ async def node_player_step(state: GraphState) -> GraphState:
77
+ logger.debug("[Graph] node_player_step state: %s", state)
78
+ user_state = get_user_state(state.user_hash)
79
+ scene_id = user_state.current_scene_id
80
+ if state.choice_text:
81
+ await update_state_with_choice.ainvoke(
82
+ {
83
+ "user_hash": state.user_hash,
84
+ "scene_id": scene_id,
85
+ "choice_text": state.choice_text,
86
+ }
87
+ )
88
+ ending = await check_ending.ainvoke({"user_hash": state.user_hash})
89
+ state.ending = ending
90
+ if not ending.get("ending_reached", False):
91
+ next_scene = await generate_scene.ainvoke(
92
+ {
93
+ "user_hash": state.user_hash,
94
+ "last_choice": state.choice_text,
95
+ }
96
+ )
97
+ change_scene = await generate_image_prompt(next_scene["description"], state.user_hash)
98
+ image_task = generate_scene_image.ainvoke(
99
+ {
100
+ "user_hash": state.user_hash,
101
+ "scene_id": next_scene["scene_id"],
102
+ "current_image": user_state.assets[scene_id],
103
+ "change_scene": change_scene,
104
+ }
105
+ )
106
+ music_task = change_music_tone(state.user_hash, next_scene["music"])
107
+ await asyncio.gather(image_task, music_task)
108
+ state.scene = next_scene
109
+ return state
110
+
111
+
112
+ def route_ending(state: GraphState) -> str:
113
+ return "game_over" if state.ending.get("ending_reached") else "continue"
114
+
115
+
116
+ async def node_game_over(state: GraphState) -> GraphState:
117
+ logger.info("[Graph] Game over for user %s", state.user_hash)
118
+ return state
119
+
120
+
121
+ def build_llm_game_graph() -> StateGraph:
122
+ graph = StateGraph(GraphState)
123
+ graph.add_node("entry", node_entry)
124
+ graph.add_node("init_game", node_init_game)
125
+ graph.add_node("player_step", node_player_step)
126
+ graph.add_node("game_over", node_game_over)
127
+
128
+ graph.set_entry_point("entry")
129
+ graph.add_conditional_edges(
130
+ "entry",
131
+ route_step,
132
+ {"init_game": "init_game", "player_step": "player_step"},
133
+ )
134
+ graph.add_edge("init_game", END)
135
+ graph.add_conditional_edges(
136
+ "player_step",
137
+ route_ending,
138
+ {"game_over": "game_over", "continue": END},
139
+ )
140
+ graph.add_edge("game_over", END)
141
+ return graph.compile()
142
+
143
+
144
+ llm_game_graph = build_llm_game_graph()
src/agent/models.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models representing game state and LLM outputs."""
2
+
3
+ from typing import Dict, List, Optional, Set
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class Milestone(BaseModel):
9
+ """Milestone that can be achieved during the story."""
10
+
11
+ id: str
12
+ description: str
13
+
14
+
15
+ class Ending(BaseModel):
16
+ """Possible game ending."""
17
+
18
+ id: str
19
+ type: str # "good" or "bad"
20
+ condition: str
21
+ description: Optional[str] = None
22
+
23
+
24
+ class StoryFrame(BaseModel):
25
+ """Overall plot information generated by the LLM."""
26
+
27
+ lore: str
28
+ goal: str
29
+ milestones: List[Milestone]
30
+ endings: List[Ending]
31
+ setting: str
32
+ character: Dict[str, str]
33
+ genre: str
34
+
35
+
36
+ class StoryFrameLLM(BaseModel):
37
+ """Structure returned by the LLM for story frame generation."""
38
+
39
+ lore: str
40
+ goal: str
41
+ milestones: List[Milestone]
42
+ endings: List[Ending]
43
+
44
+
45
+ class SceneChoice(BaseModel):
46
+ """User choice leading to another scene."""
47
+
48
+ text: str
49
+ next_scene_short_desc: str
50
+
51
+
52
+ class PlayerOption(BaseModel):
53
+ """Option presented to the player in a scene."""
54
+
55
+ option_description: str = Field(
56
+ description=(
57
+ "Description of the option, e.g. '[Say] Hello!' or "
58
+ "'Go to the forest'"
59
+ )
60
+ )
61
+
62
+
63
+ class Scene(BaseModel):
64
+ """Game scene with choices and optional assets."""
65
+
66
+ scene_id: str
67
+ description: str
68
+ choices: List[SceneChoice]
69
+ image: Optional[str] = None
70
+ music: Optional[str] = None
71
+
72
+
73
+ class SceneLLM(BaseModel):
74
+ """Structure expected from the LLM when generating a scene."""
75
+
76
+ description: str
77
+ choices: List[SceneChoice]
78
+
79
+
80
+ class EndingCheckResult(BaseModel):
81
+ """Result returned from the LLM when checking for an ending."""
82
+
83
+ ending_reached: bool = Field(default=False)
84
+ ending: Optional[Ending] = None
85
+
86
+
87
+ class UserChoice(BaseModel):
88
+ """Single player choice recorded in the history."""
89
+
90
+ scene_id: str
91
+ choice_text: str
92
+ timestamp: Optional[str] = None
93
+
94
+
95
+ class UserState(BaseModel):
96
+ """State stored for each user."""
97
+
98
+ story_frame: Optional[StoryFrame] = None
99
+ current_scene_id: Optional[str] = None
100
+ scenes: Dict[str, Scene] = Field(default_factory=dict)
101
+ milestones_achieved: Set[str] = Field(default_factory=set)
102
+ user_choices: List[UserChoice] = Field(default_factory=list)
103
+ ending: Optional[Ending] = None
104
+ assets: Dict[str, str] = Field(default_factory=dict)
src/agent/music_agent.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from agent.llm import create_light_llm
3
+ from langchain_core.messages import SystemMessage, HumanMessage
4
+ import logging
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ music_options = """Instruments: 303 Acid Bass, 808 Hip Hop Beat, Accordion, Alto Saxophone, Bagpipes, Balalaika Ensemble, Banjo, Bass Clarinet, Bongos, Boomy Bass, Bouzouki, Buchla Synths, Cello, Charango, Clavichord, Conga Drums, Didgeridoo, Dirty Synths, Djembe, Drumline, Dulcimer, Fiddle, Flamenco Guitar, Funk Drums, Glockenspiel, Guitar, Hang Drum, Harmonica, Harp, Harpsichord, Hurdy-gurdy, Kalimba, Koto, Lyre, Mandolin, Maracas, Marimba, Mbira, Mellotron, Metallic Twang, Moog Oscillations, Ocarina, Persian Tar, Pipa, Precision Bass, Ragtime Piano, Rhodes Piano, Shamisen, Shredding Guitar, Sitar, Slide Guitar, Smooth Pianos, Spacey Synths, Steel Drum, Synth Pads, Tabla, TR-909 Drum Machine, Trumpet, Tuba, Vibraphone, Viola Ensemble, Warm Acoustic Guitar, Woodwinds, ...
9
+ Music Genre: Acid Jazz, Afrobeat, Alternative Country, Baroque, Bengal Baul, Bhangra, Bluegrass, Blues Rock, Bossa Nova, Breakbeat, Celtic Folk, Chillout, Chiptune, Classic Rock, Contemporary R&B, Cumbia, Deep House, Disco Funk, Drum & Bass, Dubstep, EDM, Electro Swing, Funk Metal, G-funk, Garage Rock, Glitch Hop, Grime, Hyperpop, Indian Classical, Indie Electronic, Indie Folk, Indie Pop, Irish Folk, Jam Band, Jamaican Dub, Jazz Fusion, Latin Jazz, Lo-Fi Hip Hop, Marching Band, Merengue, New Jack Swing, Minimal Techno, Moombahton, Neo-Soul, Orchestral Score, Piano Ballad, Polka, Post-Punk, 60s Psychedelic Rock, Psytrance, R&B, Reggae, Reggaeton, Renaissance Music, Salsa, Shoegaze, Ska, Surf Rock, Synthpop, Techno, Trance, Trap Beat, Trip Hop, Vaporwave, Witch house, ...
10
+ Mood/Description: Acoustic Instruments, Ambient, Bright Tones, Chill, Crunchy Distortion, Danceable, Dreamy, Echo, Emotional, Ethereal Ambience, Experimental, Fat Beats, Funky, Glitchy Effects, Huge Drop, Live Performance, Lo-fi, Ominous Drone, Psychedelic, Rich Orchestration, Saturated Tones, Subdued Melody, Sustained Chords, Swirling Phasers, Tight Groove, Unsettling, Upbeat, Virtuoso, Weird Noises, ...
11
+ """
12
+ system_prompt = f"""
13
+ You are a music agent responsible for generating appropriate music tones for scenes in a visual novel game.
14
+
15
+ Your task is to analyze the current scene description and generate a detailed music prompt that captures:
16
+ 1. The emotional atmosphere
17
+ 2. The intensity level
18
+ 3. The genre/style that best fits the scene
19
+ 4. Specific instruments that would enhance the mood
20
+
21
+ You have access to a wide range of musical elements including:
22
+ {music_options}
23
+
24
+ When generating a music prompt:
25
+ - Consider the scene's context, mood, and any suspense elements
26
+ - Choose instruments that complement the scene's atmosphere
27
+ - Select a genre that matches the story's setting and tone
28
+ - Include specific mood descriptors to guide the music generation
29
+
30
+ Your output should be a concise but detailed prompt that the music generation model can use to create an appropriate soundtrack for the scene.
31
+ """
32
+
33
+
34
+ class MusicPrompt(BaseModel):
35
+ prompt: str
36
+
37
+
38
+ llm = create_light_llm(0.1).with_structured_output(MusicPrompt)
39
+
40
+
41
+ async def generate_music_prompt(scene_description: str, request_id: str) -> str:
42
+ logger.info(f"Generating music prompt for the current scene: {request_id}")
43
+ response = await llm.ainvoke(
44
+ [SystemMessage(content=system_prompt), HumanMessage(content=scene_description)]
45
+ )
46
+ logger.info(f"Music prompt generated: {request_id}")
47
+ return response.prompt
src/agent/prompts.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ STORY_FRAME_PROMPT = """
2
+ You are a narrative game designer. Use the player data below to
3
+ create a story frame for an interactive adventure.
4
+ Setting: {setting}
5
+ Character: {character}
6
+ Genre: {genre}
7
+ Return ONLY a JSON object with:
8
+ - lore: brief world description
9
+ - goal: main player objective
10
+ - milestones: 2-4 key events (id, description)
11
+ - endings: good/bad endings (id, type, condition, description)
12
+ Translate the lore, goal, milestones and endings into
13
+ a language of setting language.
14
+ """
15
+
16
+ SCENE_PROMPT = """
17
+ Using the provided lore and history, generate the next scene.
18
+ Lore: {lore}
19
+ Goal: {goal}
20
+ Milestones: {milestones}
21
+ Endings: {endings}
22
+ History: {history}
23
+ Last choice: {last_choice}
24
+ The scene description must be 2-3 sentences and no more than 50 words.
25
+ Each choice text must be concise, up to 7 words.
26
+ Respond ONLY with JSON containing:
27
+ - description: short summary of the scene
28
+ - choices: exactly two dicts {{"text": ..., "next_scene_short_desc": ...}}
29
+ Translate the scene description and choices into a language of lore language.
30
+ """
31
+
32
+ ENDING_CHECK_PROMPT = """
33
+ History: {history}
34
+ Endings: {endings}
35
+ Check if any ending conditions are met.
36
+ If none are met return ending_reached: false.
37
+ If an ending is reached return ending_reached: true and provide the
38
+ ending object (id, type, description).
39
+ Respond ONLY with JSON.
40
+ """
src/agent/runner.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entry point for executing a graph step."""
2
+
3
+ import logging
4
+ from dataclasses import asdict
5
+ from typing import Dict, Optional
6
+
7
+ from agent.llm_graph import GraphState, llm_game_graph
8
+ from agent.models import UserState
9
+ from agent.state import get_user_state
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ async def process_step(
15
+ user_hash: str,
16
+ step: str,
17
+ setting: Optional[str] = None,
18
+ character: Optional[dict] = None,
19
+ genre: Optional[str] = None,
20
+ choice_text: Optional[str] = None,
21
+ ) -> Dict:
22
+ """Run one interaction step through the graph."""
23
+ logger.info("[Runner] Step %s for user %s", step, user_hash)
24
+
25
+ graph_state = GraphState(user_hash=user_hash, step=step)
26
+ if step == "start":
27
+ assert setting and character and genre, "Missing start parameters"
28
+ graph_state.setting = setting
29
+ graph_state.character = character
30
+ graph_state.genre = genre
31
+ elif step == "choose":
32
+ assert choice_text, "choice_text is required"
33
+ graph_state.choice_text = choice_text
34
+
35
+ final_state = await llm_game_graph.ainvoke(asdict(graph_state))
36
+
37
+ user_state: UserState = get_user_state(user_hash)
38
+ response: Dict = {}
39
+
40
+ ending = final_state.get("ending")
41
+ if ending and ending.get("ending_reached"):
42
+ ending_info = ending["ending"]
43
+ if (
44
+ ("description" not in ending_info
45
+ or not ending_info["description"])
46
+ and user_state.story_frame
47
+ ):
48
+ for e in user_state.story_frame.endings:
49
+ if e.id == ending_info.get("id"):
50
+ ending_info["description"] = e.description
51
+ break
52
+ response["ending"] = ending_info
53
+ response["game_over"] = True
54
+ else:
55
+ if (
56
+ user_state.current_scene_id
57
+ and user_state.current_scene_id in user_state.scenes
58
+ ):
59
+ current_scene = user_state.scenes[
60
+ user_state.current_scene_id
61
+ ].dict()
62
+ else:
63
+ current_scene = final_state.get("scene")
64
+ response["scene"] = current_scene
65
+ response["game_over"] = False
66
+
67
+ return response
src/agent/state.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Simple in-memory user state storage."""
2
+
3
+ from typing import Dict
4
+
5
+ from agent.models import UserState
6
+
7
+ _USER_STATE: Dict[str, UserState] = {}
8
+
9
+
10
+ def get_user_state(user_hash: str) -> UserState:
11
+ """Return user state for the given id, creating it if necessary."""
12
+ if user_hash not in _USER_STATE:
13
+ _USER_STATE[user_hash] = UserState()
14
+ return _USER_STATE[user_hash]
15
+
16
+
17
+ def set_user_state(user_hash: str, state: UserState) -> None:
18
+ """Persist updated user state."""
19
+ _USER_STATE[user_hash] = state
20
+
21
+
22
+ def reset_user_state(user_hash: str) -> None:
23
+ """Reset stored state for a user."""
24
+ _USER_STATE[user_hash] = UserState()
src/agent/tools.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM tools used by the game graph."""
2
+
3
+ import logging
4
+ import uuid
5
+ from typing import Annotated, Dict
6
+
7
+ from langchain_core.tools import tool
8
+
9
+ from agent.llm import create_llm
10
+ from agent.models import (
11
+ EndingCheckResult,
12
+ Scene,
13
+ SceneChoice,
14
+ SceneLLM,
15
+ StoryFrame,
16
+ StoryFrameLLM,
17
+ UserChoice,
18
+ )
19
+ from agent.prompts import ENDING_CHECK_PROMPT, SCENE_PROMPT, STORY_FRAME_PROMPT
20
+ from agent.state import get_user_state, set_user_state
21
+ from images.image_generator import modify_image, generate_image
22
+ from agent.image_agent import ChangeScene
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def _err(msg: str) -> str:
28
+ logger.error(msg)
29
+ return f"{{'error': '{msg}'}}"
30
+
31
+
32
+ @tool
33
+ async def generate_story_frame(
34
+ user_hash: Annotated[str, "User session ID"],
35
+ setting: Annotated[str, "Game world setting"],
36
+ character: Annotated[Dict[str, str], "Character info"],
37
+ genre: Annotated[str, "Genre"],
38
+ ) -> Annotated[Dict, "Generated story frame"]:
39
+ """Create the initial story frame and store it in user state."""
40
+ llm = create_llm().with_structured_output(StoryFrameLLM)
41
+ prompt = STORY_FRAME_PROMPT.format(
42
+ setting=setting,
43
+ character=character,
44
+ genre=genre,
45
+ )
46
+ resp: StoryFrameLLM = await llm.ainvoke(prompt)
47
+ story_frame = StoryFrame(
48
+ lore=resp.lore,
49
+ goal=resp.goal,
50
+ milestones=resp.milestones,
51
+ endings=resp.endings,
52
+ setting=setting,
53
+ character=character,
54
+ genre=genre,
55
+ )
56
+ state = get_user_state(user_hash)
57
+ state.story_frame = story_frame
58
+ set_user_state(user_hash, state)
59
+ return story_frame.dict()
60
+
61
+
62
+ @tool
63
+ async def generate_scene(
64
+ user_hash: Annotated[str, "User session ID"],
65
+ last_choice: Annotated[str, "Last user choice"],
66
+ ) -> Annotated[Dict, "Generated scene"]:
67
+ """Generate a new scene based on the current user state."""
68
+ state = get_user_state(user_hash)
69
+ if not state.story_frame:
70
+ return _err("Story frame not initialized")
71
+ llm = create_llm().with_structured_output(SceneLLM)
72
+ prompt = SCENE_PROMPT.format(
73
+ lore=state.story_frame.lore,
74
+ goal=state.story_frame.goal,
75
+ milestones=",".join(m.id for m in state.story_frame.milestones),
76
+ endings=",".join(e.id for e in state.story_frame.endings),
77
+ history="; ".join(f"{c.scene_id}:{c.choice_text}" for c in state.user_choices),
78
+ last_choice=last_choice,
79
+ )
80
+ resp: SceneLLM = await llm.ainvoke(prompt)
81
+ if len(resp.choices) < 2:
82
+ resp = await llm.ainvoke(
83
+ prompt + "\nThe scene must contain exactly two choices."
84
+ )
85
+ scene_id = str(uuid.uuid4())
86
+ choices = [
87
+ SceneChoice(**ch.model_dump())
88
+ if hasattr(ch, "model_dump")
89
+ else SceneChoice(**ch)
90
+ for ch in resp.choices[:2]
91
+ ]
92
+ scene = Scene(
93
+ scene_id=scene_id,
94
+ description=resp.description,
95
+ choices=choices,
96
+ image=None,
97
+ music=None,
98
+ )
99
+ state.current_scene_id = scene_id
100
+ state.scenes[scene_id] = scene
101
+ set_user_state(user_hash, state)
102
+ return scene.dict()
103
+
104
+
105
+ @tool
106
+ async def generate_scene_image(
107
+ user_hash: Annotated[str, "User session ID"],
108
+ scene_id: Annotated[str, "Scene ID"],
109
+ change_scene: Annotated[ChangeScene, "Prompt for image generation"],
110
+ current_image: Annotated[str, "Current image"] | None = None,
111
+ ) -> Annotated[str, "Path to generated image"]:
112
+ """Generate an image for a scene and save the path in the state."""
113
+ try:
114
+ image_path = current_image
115
+ if change_scene.change_scene == "change_completely" or change_scene.change_scene == "modify":
116
+ image_path, _ = await (
117
+ generate_image(change_scene.scene_description)
118
+ if current_image is None
119
+ # for now always modify the image to avoid the generating an update in a completely wrong style
120
+ else modify_image(current_image, change_scene.scene_description)
121
+ )
122
+ state = get_user_state(user_hash)
123
+ if scene_id in state.scenes:
124
+ state.scenes[scene_id].image = image_path
125
+ set_user_state(user_hash, state)
126
+ return image_path
127
+ except Exception as exc: # noqa: BLE001
128
+ return _err(str(exc))
129
+
130
+
131
+ @tool
132
+ async def update_state_with_choice(
133
+ user_hash: Annotated[str, "User session ID"],
134
+ scene_id: Annotated[str, "Scene ID"],
135
+ choice_text: Annotated[str, "Chosen option"],
136
+ ) -> Annotated[Dict, "Updated state"]:
137
+ """Record the player's choice in the state."""
138
+ import datetime
139
+
140
+ state = get_user_state(user_hash)
141
+ state.user_choices.append(
142
+ UserChoice(
143
+ scene_id=scene_id,
144
+ choice_text=choice_text,
145
+ timestamp=datetime.datetime.utcnow().isoformat(),
146
+ )
147
+ )
148
+ set_user_state(user_hash, state)
149
+ return state.dict()
150
+
151
+
152
+ @tool
153
+ async def check_ending(
154
+ user_hash: Annotated[str, "User session ID"],
155
+ ) -> Annotated[Dict, "Ending check result"]:
156
+ """Check whether an ending has been reached."""
157
+ state = get_user_state(user_hash)
158
+ if not state.story_frame:
159
+ return _err("No story frame")
160
+ llm = create_llm().with_structured_output(EndingCheckResult)
161
+ history = "; ".join(f"{c.scene_id}:{c.choice_text}" for c in state.user_choices)
162
+ prompt = ENDING_CHECK_PROMPT.format(
163
+ history=history,
164
+ endings=",".join(f"{e.id}:{e.condition}" for e in state.story_frame.endings),
165
+ )
166
+ resp: EndingCheckResult = await llm.ainvoke(prompt)
167
+ if resp.ending_reached and resp.ending:
168
+ state.ending = resp.ending
169
+ set_user_state(user_hash, state)
170
+ return {"ending_reached": True, "ending": resp.ending.dict()}
171
+ return {"ending_reached": False}
src/audio/audio_generator.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from google import genai
3
+ from google.genai import types
4
+ from config import settings
5
+ import wave
6
+ import queue
7
+ import logging
8
+ import io
9
+ import time
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ client = genai.Client(api_key=settings.gemini_api_key.get_secret_value(), http_options={'api_version': 'v1alpha'})
14
+
15
+ async def generate_music(user_hash: str, music_tone: str, receive_audio):
16
+ if user_hash in sessions:
17
+ return
18
+ async with (
19
+ client.aio.live.music.connect(model='models/lyria-realtime-exp') as session,
20
+ asyncio.TaskGroup() as tg,
21
+ ):
22
+ # Set up task to receive server messages.
23
+ tg.create_task(receive_audio(session, user_hash))
24
+
25
+ # Send initial prompts and config
26
+ await session.set_weighted_prompts(
27
+ prompts=[
28
+ types.WeightedPrompt(text=music_tone, weight=1.0),
29
+ ]
30
+ )
31
+ await session.set_music_generation_config(
32
+ config=types.LiveMusicGenerationConfig(bpm=90, temperature=1.0)
33
+ )
34
+ await session.play()
35
+ logger.info(f"Started music generation for user hash {user_hash}, music tone: {music_tone}")
36
+ sessions[user_hash] = {
37
+ 'session': session,
38
+ 'queue': queue.Queue()
39
+ }
40
+
41
+ async def change_music_tone(user_hash: str, new_tone):
42
+ logger.info(f"Changing music tone to {new_tone}")
43
+ session = sessions.get(user_hash, {}).get('session')
44
+ if not session:
45
+ logger.error(f"No session found for user hash {user_hash}")
46
+ return
47
+ await session.set_weighted_prompts(
48
+ prompts=[types.WeightedPrompt(text=new_tone, weight=1.0)]
49
+ )
50
+
51
+
52
+ SAMPLE_RATE = 48000
53
+ NUM_CHANNELS = 2 # Stereo
54
+ SAMPLE_WIDTH = 2 # 16-bit audio -> 2 bytes per sample
55
+
56
+ async def receive_audio(session, user_hash):
57
+ """Process incoming audio from the music generation."""
58
+ while True:
59
+ try:
60
+ async for message in session.receive():
61
+ if message.server_content and message.server_content.audio_chunks:
62
+ audio_data = message.server_content.audio_chunks[0].data
63
+ queue = sessions[user_hash]['queue']
64
+ # audio_data is already bytes (raw PCM)
65
+ await asyncio.to_thread(queue.put, audio_data)
66
+ await asyncio.sleep(10**-12)
67
+ except Exception as e:
68
+ logger.error(f"Error in receive_audio: {e}")
69
+ break
70
+
71
+ sessions = {}
72
+
73
+ async def start_music_generation(user_hash: str, music_tone: str):
74
+ """Start the music generation in a separate thread."""
75
+ await generate_music(user_hash, music_tone, receive_audio)
76
+
77
+ async def cleanup_music_session(user_hash: str):
78
+ if user_hash in sessions:
79
+ logger.info(f"Cleaning up music session for user hash {user_hash}")
80
+ session = sessions[user_hash]['session']
81
+ await session.stop()
82
+ await session.close()
83
+ del sessions[user_hash]
84
+
85
+
86
+ def update_audio(user_hash):
87
+ """Continuously stream audio from the queue as WAV bytes."""
88
+ while True:
89
+ if user_hash not in sessions:
90
+ time.sleep(0.5)
91
+ continue
92
+ queue = sessions[user_hash]['queue']
93
+ pcm_data = queue.get() # This is raw PCM audio bytes
94
+
95
+ if not isinstance(pcm_data, bytes):
96
+ logger.warning(f"Expected bytes from audio_queue, got {type(pcm_data)}. Skipping.")
97
+ continue
98
+
99
+ # Lyria provides stereo, 16-bit PCM at 48kHz.
100
+ # Ensure the number of bytes is consistent with stereo 16-bit audio.
101
+ # Each frame = NUM_CHANNELS * SAMPLE_WIDTH bytes.
102
+ # If len(pcm_data) is not a multiple of (NUM_CHANNELS * SAMPLE_WIDTH),
103
+ # it might indicate an incomplete chunk or an issue.
104
+ bytes_per_frame = NUM_CHANNELS * SAMPLE_WIDTH
105
+ if len(pcm_data) % bytes_per_frame != 0:
106
+ logger.warning(
107
+ f"Received PCM data with length {len(pcm_data)}, which is not a multiple of "
108
+ f"bytes_per_frame ({bytes_per_frame}). This might cause issues with WAV formatting."
109
+ )
110
+ # Depending on strictness, you might want to skip this chunk:
111
+ # continue
112
+
113
+ wav_buffer = io.BytesIO()
114
+ with wave.open(wav_buffer, 'wb') as wf:
115
+ wf.setnchannels(NUM_CHANNELS)
116
+ wf.setsampwidth(SAMPLE_WIDTH) # Corresponds to 16-bit audio
117
+ wf.setframerate(SAMPLE_RATE)
118
+ wf.writeframes(pcm_data)
119
+ wav_bytes = wav_buffer.getvalue()
120
+ yield wav_bytes
src/config.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dotenv import load_dotenv
2
+ from pydantic_settings import BaseSettings
3
+ import logging
4
+ from pydantic import SecretStr
5
+
6
+ load_dotenv()
7
+
8
+
9
+ logging.basicConfig(
10
+ level=logging.INFO,
11
+ format="%(levelname)s:\t%(asctime)s [%(name)s] %(message)s",
12
+ datefmt="%Y-%m-%d %H:%M:%S %z",
13
+ )
14
+
15
+
16
+ class BaseAppSettings(BaseSettings):
17
+ """Base settings class with common configuration."""
18
+
19
+ class Config:
20
+ env_file = ".env"
21
+ env_file_encoding = "utf-8"
22
+ extra = "ignore"
23
+
24
+
25
+ class AppSettings(BaseAppSettings):
26
+ gemini_api_key: SecretStr
27
+ gemini_api_keys: SecretStr
28
+ # assistant_api_key: SecretStr
29
+ top_p: float = 0.95
30
+ temperature: float = 0.5
31
+ pregenerate_next_scene: bool = True
32
+
33
+
34
+ settings = AppSettings()
src/css.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Custom CSS for fullscreen image with overlay
2
+ custom_css = """
3
+ /* Make the image container fullscreen */
4
+ .image-container {
5
+ position: fixed !important;
6
+ top: 0 !important;
7
+ left: 0 !important;
8
+ width: 100vw !important;
9
+ height: 100vh !important;
10
+ z-index: 1 !important;
11
+ }
12
+
13
+ .image-container img {
14
+ width: 100vw !important;
15
+ height: 100vh !important;
16
+ object-fit: cover !important;
17
+ }
18
+
19
+ /* Style the overlay content */
20
+ .overlay-content {
21
+ position: fixed !important;
22
+ bottom: 0 !important;
23
+ left: 0 !important;
24
+ right: 0 !important;
25
+ background: linear-gradient(transparent, rgba(0,0,0,0.8)) !important;
26
+ padding: 40px 20px 20px !important;
27
+ z-index: 10 !important;
28
+ color: white !important;
29
+ }
30
+
31
+ /* Style the narrative text */
32
+ .narrative-text {
33
+ background: rgba(0,0,0,0.7) !important;
34
+ border: none !important;
35
+ color: white !important;
36
+ font-size: 15px !important;
37
+ line-height: 1.5 !important;
38
+ padding: 10px !important;
39
+ border-radius: 10px !important;
40
+ margin-bottom: 10px !important;
41
+ }
42
+
43
+ img {
44
+ pointer-events: none;
45
+ }
46
+
47
+ .narrative-text textarea {
48
+ background: transparent !important;
49
+ border: none !important;
50
+ color: white !important;
51
+ -webkit-text-fill-color: white !important;
52
+ font-size: 15px !important;
53
+ resize: none !important;
54
+ }
55
+
56
+ /* Style the choice buttons */
57
+ .choice-buttons {
58
+ background: rgba(0,0,0,0.7) !important;
59
+ border-radius: 10px !important;
60
+ padding: 10px !important;
61
+ }
62
+
63
+ .choice-buttons label {
64
+ color: white !important;
65
+ font-size: 14px !important;
66
+ }
67
+
68
+ /* Fix radio button backgrounds */
69
+ .choice-buttons input[type="radio"] {
70
+ background: transparent !important;
71
+ border: 2px solid white !important;
72
+ }
73
+
74
+ .choice-buttons input[type="radio"]:checked {
75
+ background: white !important;
76
+ }
77
+
78
+ .choice-buttons .form-radio {
79
+ background: transparent !important;
80
+ }
81
+
82
+ /* Style radio button containers */
83
+ .choice-buttons > div {
84
+ background: transparent !important;
85
+ }
86
+
87
+ .choice-buttons fieldset {
88
+ background: transparent !important;
89
+ border: none !important;
90
+ }
91
+
92
+ /* Remove any remaining white backgrounds */
93
+ .choice-buttons * {
94
+ background-color: transparent !important;
95
+ }
96
+
97
+ .choice-buttons input {
98
+ background-color: transparent !important;
99
+ border: 1px solid rgba(255,255,255,0.5) !important;
100
+ color: white !important;
101
+ }
102
+
103
+ .choice-buttons label span {
104
+ color: white !important;
105
+ }
106
+
107
+ /* Hide gradio header and footer */
108
+ .gradio-header, .gradio-footer {
109
+ display: none !important;
110
+ }
111
+
112
+ /* Hide image control buttons using correct DOM selector */
113
+ .image-container .icon-button-wrapper {
114
+ display: none !important;
115
+ }
116
+
117
+ .image-container .icon-buttons {
118
+ display: none !important;
119
+ }
120
+
121
+ /* Position the back button in the top-right corner */
122
+ #back-btn {
123
+ position: fixed !important;
124
+ top: 10px !important;
125
+ right: 10px !important;
126
+ z-index: 20 !important;
127
+ }
128
+
129
+ /* Make form element transparent */
130
+ .overlay-content .form {
131
+ background: transparent !important;
132
+ }
133
+ """
134
+
135
+ # CSS for the loading indicator
136
+ loading_css_styles = """
137
+ #loading-indicator {
138
+ position: fixed;
139
+ top: 0;
140
+ left: 0;
141
+ width: 100%;
142
+ height: 100%;
143
+ background-color: rgba(0, 0, 0, 0.8); /* Semi-transparent black */
144
+ /* When Gradio makes this gr.Column visible, it will set display:flex !important; (or similar). */
145
+ /* These properties will then apply to center the content of the Column: */
146
+ justify-content: center;
147
+ align-items: center;
148
+ z-index: 9999; /* Ensure it's on top */
149
+ }
150
+ #loading-indicator .loading-text { /* Style for the text inside */
151
+ color: white;
152
+ font-size: 2em;
153
+ text-align: center;
154
+ }
155
+ """
src/game_constructor.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import json
3
+ import uuid
4
+ from game_setting import Character, GameSetting, get_user_story
5
+ from game_state import story, state, get_current_scene
6
+ from agent.llm_agent import process_user_input
7
+ from images.image_generator import generate_image
8
+ from game_setting import Character, GameSetting
9
+ from agent.runner import process_step
10
+ from audio.audio_generator import start_music_generation
11
+ import asyncio
12
+ from config import settings
13
+
14
+
15
+ # Predefined suggestions for demo
16
+ SETTING_SUGGESTIONS = [
17
+ "A mystical forest shrouded in eternal twilight, where ancient trees whisper secrets and magical creatures roam freely",
18
+ "A sprawling cyberpunk metropolis in 2099, where neon lights illuminate towering skyscrapers and technology controls every aspect of life",
19
+ "A Victorian-era mansion on a remote cliff, filled with hidden passages, antique furniture, and an atmosphere of dark mysteries",
20
+ "A post-apocalyptic wasteland where survivors struggle to rebuild civilization among the ruins of the old world",
21
+ "A magical academy floating in the clouds, where young wizards learn to master their powers and uncover ancient spells",
22
+ ]
23
+
24
+ CHARACTER_SUGGESTIONS = [
25
+ {
26
+ "name": "Elena Nightwhisper",
27
+ "age": "25",
28
+ "background": "A skilled detective with supernatural intuition, haunted by visions of crimes before they happen",
29
+ "personality": "Determined, intuitive, struggles with self-doubt but fiercely protective of the innocent",
30
+ },
31
+ {
32
+ "name": "Marcus Steelborn",
33
+ "age": "32",
34
+ "background": "A former soldier turned cybernetic engineer in a dystopian future, seeking to expose corporate corruption",
35
+ "personality": "Brave, tech-savvy, has trust issues but deeply loyal to those who earn his respect",
36
+ },
37
+ {
38
+ "name": "Aria Moonstone",
39
+ "age": "19",
40
+ "background": "A young witch discovering her powers while attending a prestigious magical academy",
41
+ "personality": "Curious, ambitious, sometimes reckless but has a good heart and strong sense of justice",
42
+ },
43
+ {
44
+ "name": "Dr. Victoria Blackthorne",
45
+ "age": "45",
46
+ "background": "A renowned archaeologist who specializes in occult artifacts and ancient mysteries",
47
+ "personality": "Intelligent, sophisticated, perfectionist with a hidden romantic side",
48
+ },
49
+ ]
50
+
51
+ GENRE_OPTIONS = [
52
+ "Horror - Supernatural terror and psychological thrills",
53
+ "Detective/Mystery - Crime solving and investigation",
54
+ "Romance - Love stories and relationship drama",
55
+ "Fantasy - Magic and mythical creatures",
56
+ "Sci-Fi - Futuristic technology and space exploration",
57
+ "Adventure - Action-packed journeys and quests",
58
+ "Psychological Thriller - Mind games and suspense",
59
+ "Historical Fiction - Stories set in past eras",
60
+ ]
61
+
62
+
63
+ def load_setting_suggestion(suggestion: str):
64
+ """Load a predefined setting suggestion"""
65
+ return suggestion
66
+
67
+
68
+ def load_character_suggestion(character_name: str):
69
+ """Load a predefined character suggestion"""
70
+ if character_name == "None":
71
+ return "", "", "", ""
72
+
73
+ for char in CHARACTER_SUGGESTIONS:
74
+ if char["name"] in character_name:
75
+ return char["name"], char["age"], char["background"], char["personality"]
76
+ return "", "", "", ""
77
+
78
+
79
+ def save_game_config(
80
+ setting_desc: str,
81
+ char_name: str,
82
+ char_age: str,
83
+ char_background: str,
84
+ char_personality: str,
85
+ genre: str,
86
+ ):
87
+ """Save the game configuration to a JSON file"""
88
+ if not all(
89
+ [setting_desc, char_name, char_age, char_background, char_personality, genre]
90
+ ):
91
+ return "❌ Please fill in all fields before saving."
92
+
93
+ config = {
94
+ "id": str(uuid.uuid4()),
95
+ "setting": {"description": setting_desc},
96
+ "character": {
97
+ "name": char_name,
98
+ "age": char_age,
99
+ "background": char_background,
100
+ "personality": char_personality,
101
+ },
102
+ "genre": genre,
103
+ "created_at": str(uuid.uuid4()), # In real app, would use actual timestamp
104
+ }
105
+
106
+ try:
107
+ filename = f"game_config_{config['id'][:8]}.json"
108
+ with open(f"generated/{filename}", "w") as f:
109
+ json.dump(config, f, indent=2)
110
+ return f"✅ Game configuration saved as {filename}"
111
+ except Exception as e:
112
+ return f"❌ Error saving configuration: {str(e)}"
113
+
114
+
115
+ async def start_game_with_settings(
116
+ user_hash: str,
117
+ setting_desc: str,
118
+ char_name: str,
119
+ char_age: str,
120
+ char_background: str,
121
+ char_personality: str,
122
+ genre: str,
123
+ ):
124
+ """Initialize the game with custom settings and switch to game interface"""
125
+ if not all(
126
+ [setting_desc, char_name, char_age, char_background, char_personality, genre]
127
+ ):
128
+ return (
129
+ gr.update(visible=True), # constructor_interface
130
+ gr.update(visible=False), # loading indicator
131
+ gr.update(visible=False), # game_interface
132
+ gr.update(
133
+ value="❌ Please fill in all fields before starting the game.",
134
+ visible=True,
135
+ ), # error_message
136
+ gr.update(),
137
+ gr.update(),
138
+ gr.update(), # game components unchanged
139
+ )
140
+
141
+ character = Character(
142
+ name=char_name,
143
+ age=char_age,
144
+ background=char_background,
145
+ personality=char_personality,
146
+ )
147
+
148
+ game_setting = GameSetting(character=character, setting=setting_desc, genre=genre)
149
+
150
+ asyncio.create_task(start_music_generation(user_hash, "neutral"))
151
+
152
+ # Запускаем LLM-граф для инициализации истории
153
+ result = await process_step(
154
+ user_hash=user_hash,
155
+ step="start",
156
+ setting=game_setting.setting,
157
+ character=game_setting.character.model_dump(),
158
+ genre=game_setting.genre,
159
+ )
160
+
161
+ scene = result["scene"]
162
+ scene_text = scene["description"]
163
+ scene_image = scene.get("image", "")
164
+ scene_choices = [ch["text"] for ch in scene.get("choices", [])]
165
+
166
+ return (
167
+ gr.update(visible=False), # loading indicator
168
+ gr.update(visible=False), # constructor_interface
169
+ gr.update(visible=True), # game_interface
170
+ gr.update(visible=False), # error_message
171
+ gr.update(value=scene_text), # game_text
172
+ gr.update(value=scene_image), # game_image
173
+ gr.update(choices=scene_choices, value=None), # game_choices
174
+ )
src/game_setting.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class Character(BaseModel):
5
+ name: str
6
+ age: str
7
+ background: str
8
+ personality: str
9
+
10
+
11
+ class GameSetting(BaseModel):
12
+ character: Character
13
+ setting: str
14
+ genre: str
15
+
16
+
17
+ def get_user_story(
18
+ scene_description: str, scene_image_description: str, user_choice: str
19
+ ) -> str:
20
+ return f"""Current scene description:
21
+ {scene_description}
22
+ Current scene image description: {scene_image_description}
23
+
24
+ User's choice: {user_choice}
25
+ """
src/game_state.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ story = {
2
+ "start": {
3
+ "text": "You wake up in a mysterious forest. What do you do?",
4
+ "image": "forest.jpg",
5
+ "choices": {"Explore": None, "Wait": None},
6
+ "music_tone": "neutral",
7
+ "img_description": "forest in the fog",
8
+ },
9
+ }
10
+
11
+ state = {"scene": "start"}
12
+
13
+ def get_current_scene():
14
+ scene = story[state["scene"]]
15
+ return scene["text"], scene["image"], scene["choices"].keys()
src/images/image_generator.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from google import genai
2
+ from google.genai import types
3
+ import os
4
+ from PIL import Image
5
+ from io import BytesIO
6
+ from datetime import datetime
7
+ from config import settings
8
+ import logging
9
+ import asyncio
10
+ import gradio as gr
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ client = genai.Client(api_key=settings.gemini_api_key.get_secret_value()).aio
15
+
16
+ safety_settings = [
17
+ types.SafetySetting(
18
+ category="HARM_CATEGORY_HARASSMENT",
19
+ threshold="BLOCK_NONE", # Block none
20
+ ),
21
+ types.SafetySetting(
22
+ category="HARM_CATEGORY_HATE_SPEECH",
23
+ threshold="BLOCK_NONE", # Block none
24
+ ),
25
+ types.SafetySetting(
26
+ category="HARM_CATEGORY_SEXUALLY_EXPLICIT",
27
+ threshold="BLOCK_NONE", # Block none
28
+ ),
29
+ types.SafetySetting(
30
+ category="HARM_CATEGORY_DANGEROUS_CONTENT",
31
+ threshold="BLOCK_NONE", # Block none
32
+ ),
33
+ ]
34
+
35
+
36
+ async def generate_image(prompt: str) -> tuple[str, str] | None:
37
+ """
38
+ Generate an image using Google's Gemini model and save it to generated/images directory.
39
+
40
+ Args:
41
+ prompt (str): The text prompt to generate the image from
42
+
43
+ Returns:
44
+ str: Path to the generated image file, or None if generation failed
45
+ """
46
+ # Ensure the generated/images directory exists
47
+ output_dir = "generated/images"
48
+ os.makedirs(output_dir, exist_ok=True)
49
+
50
+ logger.info(f"Generating image with prompt: {prompt}")
51
+
52
+ try:
53
+ response = await client.models.generate_content(
54
+ model="gemini-2.0-flash-preview-image-generation",
55
+ contents=prompt,
56
+ config=types.GenerateContentConfig(
57
+ response_modalities=["TEXT", "IMAGE"],
58
+ safety_settings=safety_settings,
59
+ ),
60
+ )
61
+
62
+ # Process the response parts
63
+ image_saved = False
64
+ for part in response.candidates[0].content.parts:
65
+ if part.inline_data is not None:
66
+ # Create a filename with timestamp
67
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
68
+ filename = f"gemini_{timestamp}.png"
69
+ filepath = os.path.join(output_dir, filename)
70
+
71
+ # Save the image
72
+ image = Image.open(BytesIO(part.inline_data.data))
73
+ await asyncio.to_thread(image.save, filepath, "PNG")
74
+ logger.info(f"Image saved to: {filepath}")
75
+ image_saved = True
76
+
77
+ return filepath, prompt
78
+
79
+ if not image_saved:
80
+ gr.Warning("Image was censored by Google!")
81
+ logger.error("No image was generated in the response.")
82
+ return None, None
83
+
84
+ except Exception as e:
85
+ logger.error(f"Error generating image: {e}")
86
+ return None, None
87
+
88
+
89
+ async def modify_image(image_path: str, modification_prompt: str) -> str | None:
90
+ """
91
+ Modify an existing image using Google's Gemini model based on a text prompt.
92
+
93
+ Args:
94
+ image_path (str): Path to the existing image file
95
+ modification_prompt (str): The text prompt describing how to modify the image
96
+
97
+ Returns:
98
+ str: Path to the modified image file, or None if modification failed
99
+ """
100
+ # Ensure the generated/images directory exists
101
+ output_dir = "generated/images"
102
+ os.makedirs(output_dir, exist_ok=True)
103
+
104
+ logger.info(f"Modifying current scene image with prompt: {modification_prompt}")
105
+
106
+ # Check if the input image exists
107
+ if not os.path.exists(image_path):
108
+ logger.error(f"Error: Image file not found at {image_path}")
109
+ return None
110
+
111
+ key = settings.gemini_api_key.get_secret_value()
112
+
113
+ client = genai.Client(api_key=key).aio
114
+
115
+ try:
116
+ # Load the input image
117
+ input_image = Image.open(image_path)
118
+
119
+ # Make the API call with both text and image
120
+ response = await client.models.generate_content(
121
+ model="gemini-2.0-flash-preview-image-generation",
122
+ contents=[modification_prompt, input_image],
123
+ config=types.GenerateContentConfig(
124
+ response_modalities=["TEXT", "IMAGE"],
125
+ safety_settings=safety_settings,
126
+ ),
127
+ )
128
+
129
+ # Process the response parts
130
+ image_saved = False
131
+ for part in response.candidates[0].content.parts:
132
+ if part.inline_data is not None:
133
+ # Create a filename with timestamp
134
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
135
+ filename = f"gemini_modified_{timestamp}.png"
136
+ filepath = os.path.join(output_dir, filename)
137
+
138
+ # Save the modified image
139
+ modified_image = Image.open(BytesIO(part.inline_data.data))
140
+ await asyncio.to_thread(modified_image.save, filepath, "PNG")
141
+ logger.info(f"Modified image saved to: {filepath}")
142
+ image_saved = True
143
+
144
+ return filepath, modification_prompt
145
+
146
+ if not image_saved:
147
+ gr.Warning("Updated image was censored by Google!")
148
+ logger.error("No modified image was generated in the response.")
149
+ return None, None
150
+
151
+ except Exception as e:
152
+ logger.error(f"Error modifying image: {e}")
153
+ return None, None
154
+
155
+
156
+ if __name__ == "__main__":
157
+ # Example usage
158
+ sample_prompt = "A Luke Skywalker half height sprite with white background for visual novel game"
159
+ generated_image_path = generate_image(sample_prompt)
160
+
161
+ # if generated_image_path:
162
+ # # Example modification
163
+ # modification_prompt = "Now the house is destroyed, and the jawas are running away"
164
+ # modified_image_path = modify_image(generated_image_path, modification_prompt)
165
+ # if modified_image_path:
166
+ # print(f"Successfully modified image: {modified_image_path}")
src/main.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from css import custom_css, loading_css_styles
3
+ from audio.audio_generator import (
4
+ update_audio,
5
+ cleanup_music_session,
6
+ )
7
+ import logging
8
+ from agent.llm_agent import process_user_input
9
+ from images.image_generator import modify_image
10
+ from agent.runner import process_step
11
+ import uuid
12
+ from game_constructor import (
13
+ SETTING_SUGGESTIONS,
14
+ CHARACTER_SUGGESTIONS,
15
+ GENRE_OPTIONS,
16
+ load_setting_suggestion,
17
+ load_character_suggestion,
18
+ start_game_with_settings,
19
+ )
20
+ import asyncio
21
+ from game_setting import get_user_story
22
+ from config import settings
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ async def return_to_constructor(user_hash: str):
28
+ """Return to the constructor and reset user state and audio."""
29
+ from agent.state import reset_user_state
30
+
31
+ reset_user_state(user_hash)
32
+ await cleanup_music_session(user_hash)
33
+ # Generate a new hash to avoid stale state
34
+ new_hash = str(uuid.uuid4())
35
+ return (
36
+ gr.update(visible=False), # loading_indicator
37
+ gr.update(visible=True), # constructor_interface
38
+ gr.update(visible=False), # game_interface
39
+ gr.update(visible=False), # error_message
40
+ gr.update(value=new_hash), # local_storage
41
+ )
42
+
43
+
44
+ async def update_scene(user_hash: str, choice):
45
+ logger.info(f"Updating scene with choice: {choice}")
46
+ if not isinstance(choice, str):
47
+ return gr.update(), gr.update(), gr.update()
48
+
49
+ result = await process_step(
50
+ user_hash=user_hash,
51
+ step="choose",
52
+ choice_text=choice,
53
+ )
54
+
55
+ if result.get("game_over"):
56
+ ending = result["ending"]
57
+ ending_text = ending.get("description") or ending.get("condition", "")
58
+ return (
59
+ gr.update(value=ending_text),
60
+ gr.update(value=None),
61
+ gr.Radio(choices=[], label="", value=None),
62
+ )
63
+
64
+ scene = result["scene"]
65
+ return (
66
+ scene["description"],
67
+ scene.get("image", ""),
68
+ gr.Radio(
69
+ choices=[ch["text"] for ch in scene.get("choices", [])],
70
+ label="What do you choose?",
71
+ value=None,
72
+ elem_classes=["choice-buttons"],
73
+ ),
74
+ )
75
+
76
+
77
+ def update_preview(setting, name, age, background, personality, genre):
78
+ """Update the configuration preview"""
79
+ if not any([setting, name, age, background, personality]):
80
+ return "Fill in the fields to see a preview..."
81
+
82
+ preview = f"""🌍 SETTING: {setting[:100]}{"..." if len(setting) > 100 else ""}
83
+
84
+ 👤 CHARACTER: {name} (Age: {age})
85
+ 📖 Background: {background}
86
+ 💭 Personality: {personality}
87
+
88
+ 🎭 GENRE: {genre}"""
89
+ return preview
90
+
91
+
92
+ async def start_game_with_music(
93
+ user_hash: str,
94
+ setting_desc: str,
95
+ char_name: str,
96
+ char_age: str,
97
+ char_background: str,
98
+ char_personality: str,
99
+ genre: str,
100
+ ):
101
+ """Start the game with custom settings and initialize music"""
102
+ yield (
103
+ gr.update(visible=True), # loading indicator
104
+ gr.update(), # constructor_interface
105
+ gr.update(), # game_interface
106
+ gr.update(), # error_message
107
+ gr.update(),
108
+ gr.update(),
109
+ gr.update(), # game components unchanged
110
+ )
111
+
112
+ # First, get the game interface updates
113
+ result = await start_game_with_settings(
114
+ user_hash,
115
+ setting_desc,
116
+ char_name,
117
+ char_age,
118
+ char_background,
119
+ char_personality,
120
+ genre,
121
+ )
122
+ yield result
123
+
124
+
125
+ with gr.Blocks(
126
+ theme="soft",
127
+ title="Game Constructor & Visual Novel",
128
+ css=custom_css + loading_css_styles,
129
+ ) as demo:
130
+ # Fullscreen Loading Indicator (hidden by default)
131
+ with gr.Column(visible=False, elem_id="loading-indicator") as loading_indicator:
132
+ gr.HTML("<div class='loading-text'>🚀 Starting your adventure...</div>")
133
+
134
+ local_storage = gr.BrowserState(str(uuid.uuid4()), "user_hash")
135
+
136
+ # Constructor Interface (visible by default)
137
+ with gr.Column(
138
+ visible=True, elem_id="constructor-interface"
139
+ ) as constructor_interface:
140
+ gr.Markdown("# 🎮 Interactive Game Constructor")
141
+ gr.Markdown(
142
+ "Create your own interactive story game by defining the setting, character, and genre!"
143
+ )
144
+
145
+ # Error message area
146
+ error_message = gr.Textbox(
147
+ label="⚠️ Error",
148
+ visible=False,
149
+ interactive=False,
150
+ elem_classes=["error-message"],
151
+ )
152
+
153
+ with gr.Row():
154
+ with gr.Column(scale=2):
155
+ # Setting Description Section
156
+ with gr.Group():
157
+ gr.Markdown("## 🌍 Setting Description")
158
+ setting_suggestions = gr.Dropdown(
159
+ choices=["Select a suggestion..."] + SETTING_SUGGESTIONS,
160
+ label="Quick Suggestions",
161
+ value="Select a suggestion...",
162
+ interactive=True,
163
+ )
164
+ setting_description = gr.Textbox(
165
+ label="Describe your game setting",
166
+ placeholder="Enter a detailed description of where your story takes place...",
167
+ lines=4,
168
+ max_lines=6,
169
+ )
170
+
171
+ # Character Description Section
172
+ with gr.Group():
173
+ gr.Markdown("## 👤 Character Description")
174
+ character_suggestions = gr.Dropdown(
175
+ choices=["None"]
176
+ + [
177
+ f"{char['name']} - {char['background'][:50]}..."
178
+ for char in CHARACTER_SUGGESTIONS
179
+ ],
180
+ label="Character Templates",
181
+ value="None",
182
+ interactive=True,
183
+ )
184
+
185
+ with gr.Row():
186
+ char_name = gr.Textbox(
187
+ label="Character Name",
188
+ placeholder="Enter character name...",
189
+ )
190
+ char_age = gr.Textbox(label="Age", placeholder="25")
191
+
192
+ char_background = gr.Textbox(
193
+ label="Background/Profession",
194
+ placeholder="Describe your character's background, profession, or role...",
195
+ lines=2,
196
+ )
197
+ char_personality = gr.Textbox(
198
+ label="Personality & Traits",
199
+ placeholder="Describe personality, quirks, motivations, fears...",
200
+ lines=2,
201
+ )
202
+
203
+ # Genre Selection Section
204
+ with gr.Group():
205
+ gr.Markdown("## 🎭 Genre & Style")
206
+ genre_selection = gr.Dropdown(
207
+ choices=GENRE_OPTIONS,
208
+ label="Choose your story genre",
209
+ value=GENRE_OPTIONS[0],
210
+ interactive=True,
211
+ )
212
+
213
+ with gr.Column(scale=1):
214
+ # Preview Section
215
+ with gr.Group():
216
+ gr.Markdown("## 📋 Configuration Preview")
217
+ preview_box = gr.Textbox(
218
+ label="Game Summary",
219
+ lines=8,
220
+ interactive=False,
221
+ placeholder="Fill in the fields to see a preview...",
222
+ )
223
+
224
+ with gr.Group():
225
+ gr.Markdown("## 🎮 Ready to Play?")
226
+ start_btn = gr.Button("▶️ Start Game", variant="primary", size="lg")
227
+
228
+ with gr.Column(visible=False, elem_id="game-interface") as game_interface:
229
+ gr.Markdown("# 🎮 Your Interactive Story")
230
+
231
+ with gr.Row():
232
+ gr.Markdown("### Playing your custom game!")
233
+ back_btn = gr.Button(
234
+ "⬅️ Back to Constructor",
235
+ variant="secondary",
236
+ elem_id="back-btn",
237
+ )
238
+
239
+ # Audio component for background music
240
+ audio_out = gr.Audio(
241
+ autoplay=True, streaming=True, interactive=False, visible=False
242
+ )
243
+
244
+ # Background image (fullscreen)
245
+ with gr.Column(elem_classes=["image-container"]):
246
+ game_image = gr.Image(type="filepath", interactive=False, show_label=False)
247
+
248
+ # Overlay content (text and buttons)
249
+ with gr.Column(elem_classes=["overlay-content"]):
250
+ game_text = gr.Textbox(
251
+ label="",
252
+ interactive=False,
253
+ show_label=False,
254
+ elem_classes=["narrative-text"],
255
+ lines=3,
256
+ )
257
+ game_choices = gr.Radio(
258
+ choices=[],
259
+ label="What do you choose?",
260
+ value=None,
261
+ elem_classes=["choice-buttons"],
262
+ )
263
+
264
+ # Event handlers for constructor interface
265
+ setting_suggestions.change(
266
+ fn=load_setting_suggestion,
267
+ inputs=[setting_suggestions],
268
+ outputs=[setting_description],
269
+ )
270
+
271
+ character_suggestions.change(
272
+ fn=load_character_suggestion,
273
+ inputs=[character_suggestions],
274
+ outputs=[char_name, char_age, char_background, char_personality],
275
+ )
276
+
277
+ # Update preview when any field changes
278
+ for component in [
279
+ setting_description,
280
+ char_name,
281
+ char_age,
282
+ char_background,
283
+ char_personality,
284
+ genre_selection,
285
+ ]:
286
+ component.change(
287
+ fn=update_preview,
288
+ inputs=[
289
+ setting_description,
290
+ char_name,
291
+ char_age,
292
+ char_background,
293
+ char_personality,
294
+ genre_selection,
295
+ ],
296
+ outputs=[preview_box],
297
+ )
298
+
299
+ # Interface switching handlers
300
+ start_btn.click(
301
+ fn=start_game_with_music,
302
+ inputs=[
303
+ local_storage,
304
+ setting_description,
305
+ char_name,
306
+ char_age,
307
+ char_background,
308
+ char_personality,
309
+ genre_selection,
310
+ ],
311
+ outputs=[
312
+ loading_indicator,
313
+ constructor_interface,
314
+ game_interface,
315
+ error_message,
316
+ game_text,
317
+ game_image,
318
+ game_choices,
319
+ ],
320
+ )
321
+
322
+ back_btn.click(
323
+ fn=return_to_constructor,
324
+ inputs=[local_storage],
325
+ outputs=[
326
+ loading_indicator,
327
+ constructor_interface,
328
+ game_interface,
329
+ error_message,
330
+ local_storage,
331
+ ],
332
+ )
333
+
334
+ game_choices.change(
335
+ fn=update_scene,
336
+ inputs=[local_storage, game_choices],
337
+ outputs=[game_text, game_image, game_choices],
338
+ )
339
+
340
+ demo.unload(cleanup_music_session)
341
+ demo.load(
342
+ fn=update_audio,
343
+ inputs=[local_storage],
344
+ outputs=[audio_out],
345
+ )
346
+
347
+ demo.launch(ssr_mode=False)