Spaces:
Runtime error
Runtime error
feat: Implement interactive narrative game framework
Browse files- .env.example +1 -0
- .gitignore +178 -0
- requirements.txt +16 -0
- src/agent/image_agent.py +82 -0
- src/agent/llm.py +59 -0
- src/agent/llm_agent.py +73 -0
- src/agent/llm_graph.py +144 -0
- src/agent/models.py +104 -0
- src/agent/music_agent.py +47 -0
- src/agent/prompts.py +40 -0
- src/agent/runner.py +67 -0
- src/agent/state.py +24 -0
- src/agent/tools.py +171 -0
- src/audio/audio_generator.py +120 -0
- src/config.py +34 -0
- src/css.py +155 -0
- src/game_constructor.py +174 -0
- src/game_setting.py +25 -0
- src/game_state.py +15 -0
- src/images/image_generator.py +166 -0
- src/main.py +347 -0
.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)
|