File size: 8,352 Bytes
2e277ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4656d22
1c9a1a6
6294c0d
2e277ca
4656d22
2e277ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4000e70
2e277ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4656d22
1fd0bdd
2e277ca
 
 
 
 
 
 
 
 
 
4656d22
2e277ca
 
 
 
 
 
 
 
4656d22
2e277ca
 
 
 
 
1b005cd
14ea97e
2e277ca
1b005cd
 
 
 
 
 
 
2e277ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14ea97e
 
 
 
1fd0bdd
 
 
 
 
2e277ca
 
 
 
 
 
0913109
 
 
32570a4
 
ea32eab
2e277ca
ea32eab
 
32570a4
14ea97e
2b8aa30
 
32570a4
 
 
 
0913109
14ea97e
32570a4
 
1b005cd
 
 
 
 
 
 
 
2e277ca
32570a4
 
 
 
 
 
0913109
1b005cd
df68cdc
14ea97e
 
 
 
0913109
 
df68cdc
0913109
2e277ca
 
1927a5d
2e277ca
 
 
 
 
 
 
 
 
 
 
 
0913109
 
78c1caf
 
2e277ca
 
78c1caf
 
2e277ca
 
0913109
2e277ca
 
78c1caf
 
2e277ca
 
78c1caf
2e277ca
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
import os
import asyncio
import textwrap
import re
import httpx
import gradio as gr
from PIL import Image
from io import BytesIO

from llama_index.core.workflow import (
    Workflow,
    step,
    StartEvent,
    StopEvent,
    Context,
    Event,
)
from llama_index.llms.groq import Groq

# --- Secrets ---
GROQ_API_KEY = os.environ.get("Groq_Token")
HF_TOKEN = os.environ.get("HF_TOKEN2")

# --- Event and Workflow Definitions ---
class StoryContext(Event):
    story_part: str
    inventory: list[str]

class SceneReadyEvent(Event):
    pass

class UserChoice(Event):
    choice: str

class StoryEnd(Event):
    final_message: str

# Helper function to generate an image and return its path
async def generate_image(prompt: str, hf_token: str) -> str | None:
    API_URL = "https://api-inference.huggingface.co/models/black-forest-labs/FLUX.1-schnell"
    headers = {"Authorization": f"Bearer {hf_token}"}
    full_prompt = f"epic fantasy art, digital painting, cinematic lighting, masterpiece, {prompt}"
    payload = {"inputs": full_prompt}
    
    try:
        async with httpx.AsyncClient() as client:
            response = await client.post(API_URL, headers=headers, json=payload, timeout=180.0)
        response.raise_for_status()
        image = Image.open(BytesIO(response.content))
        # Save to a temporary file that Gradio can serve
        image.save("scene_image.png")
        return "scene_image.png"
    except (httpx.TimeoutException, httpx.RequestError, IOError) as e:
        print(f"Image generation failed: {e}")
        return None

# The full workflow class

class StorytellerWorkflow(Workflow):
    def __init__(self, **kwargs):
        super().__init__(timeout=300, **kwargs)

    @step
    async def generate_story_part(self, ev: StartEvent | UserChoice, ctx: Context) -> StoryContext | StoryEnd:
        inventory = await ctx.store.get("inventory", [])
        prompt = ""

        if isinstance(ev, StartEvent):
            # The prompt for the first turn
            prompt = """
            You are a creative text adventure game master. Your output is for a console game.
            Start a new story about a curious explorer entering a recently discovered, glowing cave.
            Keep the tone mysterious and exciting. After the story part, provide two distinct choices for the player to make.
            Format your response exactly like this: STORY: [The story text goes here] CHOICES: 1. [First choice] 2. [Second choice]
            """
        elif isinstance(ev, UserChoice):
            last_story_part = await ctx.store.get("last_story_part")
            # --- EXPLICIT PROMPT ---
            prompt = f"""
            You are a creative text adventure game master.
            The story so far: "{last_story_part}"
            The player chose: "{ev.choice}"
            The player's inventory: {inventory}

            Continue the story.
            If a choice results in an item, use `[ADD_ITEM: item name]`. If the story should end, write "[END]".

            Format your response exactly like this:
            STORY:
            [The story text goes here]
            CHOICES:
            1. [First choice on its own line]
            2. [Second choice on its own line]
            """

        llm = Groq(model="llama3-8b-8192", api_key=GROQ_API_KEY)
        response = await llm.acomplete(prompt)
        response_text = str(response)

        items_found = re.findall(r"\[ADD_ITEM: (.*?)\]", response_text)
        if items_found:
            for item in items_found:
                if item not in inventory:
                    inventory.append(item)
            response_text = re.sub(r"\[ADD_ITEM: (.*?)\]", "", response_text).strip()

        if response_text.strip().startswith("[END]"):
            final_message = response_text.strip().replace("[END]", "")
            return StoryEnd(final_message=f"\n--- THE END ---\n{final_message}")

        try:
            story_section = response_text.split("STORY:")[1].split("CHOICES:")[0].strip()
            choices_section = response_text.split("CHOICES:")[1].strip()
            full_story_part = f"{story_section}\n\nChoices:\n{choices_section}"
        except IndexError:
            full_story_part = "The story continues... but the path is blurry."
        
        await ctx.store.set("last_story_part", full_story_part)
        await ctx.store.set("inventory", inventory)
        
        # Return the simplified event, without the is_new_scene flag
        return StoryContext(story_part=full_story_part, inventory=inventory)
        
    @step
    def end_story(self, ev: StoryEnd) -> StopEvent:
        """This step satisfies the workflow validator by providing a path to a StopEvent."""
        return StopEvent(result=ev.final_message)
        
# These two steps are no longer needed, as Gradio's UI will handle the display logic.
# @step async def display_scene(...)
# @step async def get_user_choice(...)


# --- Gradio UI and Application Logic ---

async def run_turn(user_input, game_state):
    # This function no longer needs the component objects passed in.
    if game_state is None:
        game_state = {'inventory': [], 'last_story_part': None}
        event = StartEvent()
    else:
        event = UserChoice(choice=user_input)

    workflow = StorytellerWorkflow()
    ctx = Context(workflow=workflow)
    await ctx.store.set("inventory", game_state['inventory'])
    await ctx.store.set("last_story_part", game_state['last_story_part'])

    result_event = await workflow.generate_story_part(event, ctx)

    if isinstance(result_event, StoryEnd):
        yield (None, result_event.final_message, "", None)
        return

    if isinstance(result_event, StoryContext):
        narrative, choices_text = result_event.story_part.split("Choices:", 1)
        
        # --- THIS IS THE NEW DEFENSIVE CODE ---
        # It finds any choice number (like " 2." or " 3.") that has a space before it
        # and replaces that space with a newline character. This forces them to be on separate lines.
        choices_text = re.sub(r" (\d\.)", r"\n\1", choices_text)

        story_display_text = f"{textwrap.fill(narrative, width=80)}\n\nChoices:{choices_text}"
        
        new_game_state = {
            'inventory': result_event.inventory,
            'last_story_part': result_event.story_part
        }
        inventory_text = f"**Inventory:** {', '.join(new_game_state['inventory']) if new_game_state['inventory'] else 'Empty'}"

        yield (None, story_display_text, inventory_text, new_game_state)
        
        # 2. Generate the image.
        image_path = None
        if HF_TOKEN:
            image_path = await generate_image(narrative, HF_TOKEN)
        
        # 3. Yield a second, complete tuple with the new image path.
        yield (image_path, story_display_text, inventory_text, new_game_state)
        

def create_demo():
    with gr.Blocks(theme=gr.themes.Soft()) as demo:
        game_state = gr.State(None)

        gr.Markdown("# LlamaIndex Workflow: Dynamic Storyteller")
        gr.Markdown("An AI-powered text adventure game where every scene can be illustrated by AI.")

        with gr.Row():
            with gr.Column(scale=1):
                image_display = gr.Image(label="Scene", interactive=False)
                inventory_display = gr.Markdown("**Inventory:** Empty")
            with gr.Column(scale=2):
                story_display = gr.Textbox(label="Story", lines=15, interactive=False)
                user_input = gr.Textbox(label="What do you do?", placeholder="Type your choice and press Enter...")
        
        # The inputs and outputs lists should be simple.
        inputs = [user_input, game_state]
        outputs = [image_display, story_display, inventory_display, game_state]

        user_input.submit(
            fn=run_turn,
            inputs=inputs,
            outputs=outputs
        )
        
        load_inputs = [gr.State(None), game_state]
        demo.load(
            fn=run_turn,
            inputs=load_inputs,
            outputs=outputs
        )
    return demo
    
if __name__ == "__main__":
    if not GROQ_API_KEY or not HF_TOKEN:
        print("ERROR: API keys not found. Make sure to set GROQ_API_KEY and HF_TOKEN in your Hugging Face Space Secrets.")
    else:
        app = create_demo()
        app.launch()