ArturoNereu commited on
Commit
69242aa
·
1 Parent(s): bbb0b64
Files changed (3) hide show
  1. app.py +145 -212
  2. chat_client.py +176 -14
  3. requirements.txt +1 -0
app.py CHANGED
@@ -1,112 +1,121 @@
1
  """
2
  GCP - Game Context Protocol
3
- Build 3D game scenes with natural language
 
 
 
 
 
 
4
  """
5
  import os
6
  import json
7
- import gradio as gr
8
- import threading
9
- import uvicorn
10
  import time
11
- import requests
12
- from pathlib import Path
13
- from backend.main import app as fastapi_app
14
-
15
- # Set up static paths for Gradio to serve model files
16
- # Files are accessible at /gradio_api/file=models/<filename>
17
- MODELS_DIR = Path(__file__).parent / "models"
18
- gr.set_static_paths(paths=["models"]) # Relative path from app.py
19
-
20
- # Get base URLs from environment
21
- # Auto-detect HF Spaces environment
22
- FASTAPI_INTERNAL = "http://localhost:8000" # Always use localhost for internal calls
23
  IS_HF_SPACES = bool(os.getenv("SPACE_ID"))
 
 
 
24
  if IS_HF_SPACES:
25
- # Running on HF Spaces
26
- # Use relative URLs for iframe (browser will resolve against current origin)
27
- # This avoids CORS/auth issues with cross-origin requests
28
- FASTAPI_URL = "" # Empty = relative URLs like "/view/scene/welcome"
29
- space_host = os.getenv("SPACE_HOST", "")
30
- SPACE_URL = f"https://{space_host}" if space_host else ""
31
- print(f"🌐 HF Spaces detected: SPACE_ID={os.getenv('SPACE_ID')}, SPACE_HOST={space_host}")
32
- print(f" Using relative URLs for iframe")
33
- print(f" FASTAPI_INTERNAL={FASTAPI_INTERNAL}")
34
  else:
35
- # Local development - FastAPI on 8000, Gradio on 7860
36
- SPACE_URL = os.getenv("SPACE_URL", "http://localhost:7860")
37
  FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000")
38
  FASTAPI_INTERNAL = FASTAPI_URL
39
- print(f"💻 Local dev mode: FASTAPI_URL={FASTAPI_URL}")
40
- BASE_URL = SPACE_URL # For display in UI
41
 
42
- # Global state for current scene
43
  current_scene_id = None
44
- selected_object_id = None # Track currently looked-at object (FPS mode)
45
-
46
-
47
- def add_cache_buster(url):
48
- """Add timestamp to URL to force iframe reload"""
49
- import time
50
- timestamp = int(time.time() * 1000)
51
- separator = "&" if "?" in url else "?"
52
- return f"{url}{separator}t={timestamp}"
53
-
54
-
55
- def wait_for_fastapi(max_retries=30, retry_interval=1):
56
- """
57
- Wait for FastAPI to be ready with health check.
58
-
59
- Args:
60
- max_retries: Maximum number of health check attempts
61
- retry_interval: Seconds to wait between retries
62
-
63
- Returns:
64
- True if FastAPI is ready, False otherwise
65
- """
66
- print("\n" + "="*60)
67
- print(" Waiting for FastAPI/MCP server to be ready...")
68
- print("="*60)
69
-
70
- for i in range(max_retries):
71
- try:
72
- response = requests.get(f"{FASTAPI_INTERNAL}/health", timeout=2)
73
- if response.status_code == 200:
74
- data = response.json()
75
- print(f"\n✅ FastAPI is ready! Service: {data.get('service', 'Unknown')}")
76
- print(f" Version: {data.get('version', 'Unknown')}")
77
- print(f" Status: {data.get('status', 'Unknown')}")
78
- print("="*60 + "\n")
79
- return True
80
- except (requests.ConnectionError, requests.Timeout):
81
- if i < max_retries - 1:
82
- print(f" Attempt {i+1}/{max_retries}: FastAPI not ready yet, retrying in {retry_interval}s...")
83
- time.sleep(retry_interval)
84
- else:
85
- print(f"\n⚠️ FastAPI health check failed after {max_retries} attempts")
86
- print(" The server might still start, but there could be issues.")
87
- print("="*60 + "\n")
88
- return False
89
- except Exception as e:
90
- print(f" Unexpected error during health check: {e}")
91
- time.sleep(retry_interval)
92
-
93
- return False
94
-
95
-
96
- # Start FastAPI/MCP server in background (only for local dev)
97
- # On HF Spaces, FastAPI will be mounted on Gradio's port
 
 
 
 
 
98
  if not IS_HF_SPACES:
99
- def start_fastapi():
100
- print("\n" + "="*60)
101
- print("Starting FastAPI/MCP server on port 8000...")
102
- print("="*60 + "\n")
103
- uvicorn.run(fastapi_app, host="0.0.0.0", port=8000, log_level="info")
104
-
105
- fastapi_thread = threading.Thread(target=start_fastapi, daemon=True)
106
- fastapi_thread.start()
 
 
 
 
 
 
 
 
107
 
108
- # Wait for FastAPI to be ready
109
- wait_for_fastapi()
 
 
 
110
 
111
 
112
  def get_viewer_html(scene_id="welcome"):
@@ -153,20 +162,22 @@ def create_default_scene():
153
  return None
154
 
155
 
156
- # Initialize the GPT chat client
157
- gpt_client = None
158
 
159
- def get_gpt_client():
160
- """Get or create the GPT chat client"""
161
- global gpt_client, current_scene_id
162
- if gpt_client is None or gpt_client.scene_id != current_scene_id:
 
163
  from chat_client import GCPChatClient
164
- gpt_client = GCPChatClient(scene_id=current_scene_id, base_url=FASTAPI_URL)
165
- return gpt_client
 
166
 
167
 
168
- def chat_response(message, history, crosshair_position=None):
169
- """Handle chat messages using GPT with tool calling"""
170
  global current_scene_id
171
 
172
  # Handle help command locally (no need for LLM)
@@ -194,10 +205,12 @@ I'm an AI assistant that can help you build 3D scenes using natural language.
194
  - Press C in viewer to toggle FPS/Orbit camera
195
  - WASD to move, Space to jump in FPS mode
196
  - Click in viewer to enable mouse-look
197
- """, None
 
 
198
 
199
  try:
200
- client = get_gpt_client()
201
  # Pass crosshair position to chat client for context-aware object placement
202
  response, action_data = client.chat(message, crosshair_position=crosshair_position)
203
  return response, action_data
@@ -406,17 +419,25 @@ with gr.Blocks(title="GCP - Game Context Protocol") as demo:
406
  with gr.Column(elem_id="chat-column", scale=1, min_width=350):
407
  gr.Markdown("### 🎮 GCP - Game Context Protocol")
408
  chatbot = gr.Chatbot(
409
- height=500, # Taller to fill vertical space
410
  show_label=False,
411
  elem_id="chatbot",
412
- # Gradio 6: type="messages" is now the default, removed
413
- )
414
- msg = gr.Textbox(
415
- placeholder="'add a red cube' • 'set lighting to night' • 'help'",
416
- show_label=False,
417
- container=False,
418
- elem_id="chat-input"
419
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
 
421
  # Right column: 3D Viewer (scale=3 = ~75% width)
422
  with gr.Column(elem_id="viewer-column", scale=3):
@@ -511,8 +532,8 @@ with gr.Blocks(title="GCP - Game Context Protocol") as demo:
511
  history.append({"role": "user", "content": user_message})
512
  return "", history
513
 
514
- def bot(history, crosshair_position):
515
- """Generate bot response"""
516
  # Gradio 6: content can be a string or list of content blocks
517
  content = history[-1]["content"]
518
  if isinstance(content, list):
@@ -532,8 +553,8 @@ with gr.Blocks(title="GCP - Game Context Protocol") as demo:
532
  except:
533
  pass
534
 
535
- # Process command with crosshair context
536
- bot_message, action_result = chat_response(user_message, [], crosshair_pos_dict)
537
  history.append({"role": "assistant", "content": bot_message})
538
 
539
  # Handle action_result
@@ -547,99 +568,11 @@ with gr.Blocks(title="GCP - Game Context Protocol") as demo:
547
  # Full reload: update iframe src
548
  viewer_html = f'<div id="viewer-container" style="width:100%; min-height:500px; height:70vh;"><iframe src="{action_result["url"]}" style="width:100%; height:100%; border:none;"></iframe></div>'
549
 
550
- elif action_type in ["addObject", "removeObject", "setLighting", "setControlMode",
551
- "updateMaterial", "addLight", "removeLight", "updateLight",
552
- "setBackground", "setFog",
553
- # Player tools
554
- "setPlayerSpeed", "setJumpForce", "setGravity",
555
- "setCameraFov", "setMouseSensitivity", "setPlayerDimensions",
556
- # Environment tools
557
- "addSkybox", "removeSkybox", "addParticles", "removeParticles",
558
- # UI tools
559
- "renderText", "renderBar", "removeUIElement",
560
- # Toon shading
561
- "updateToonMaterial",
562
- # Brick blocks
563
- "addBrick"]:
564
- # Build action JSON for the JavaScript watcher
565
- import json
566
- import time
567
-
568
- # Determine toast message based on action type
569
- toast_message = ""
570
- if action_type == "addObject":
571
- obj_type = action_result["data"].get("type", "object")
572
- toast_message = f"Added {obj_type} to scene"
573
- elif action_type == "removeObject":
574
- toast_message = "Object removed"
575
- elif action_type == "setLighting":
576
- toast_message = "Lighting updated"
577
- elif action_type == "setControlMode":
578
- mode = action_result["data"].get("mode", "")
579
- toast_message = f"Switched to {mode.upper()} mode"
580
- elif action_type == "updateMaterial":
581
- toast_message = "Material updated"
582
- elif action_type == "addLight":
583
- light_name = action_result["data"].get("name", "Light")
584
- toast_message = f"Added light: {light_name}"
585
- elif action_type == "removeLight":
586
- toast_message = "Light removed"
587
- elif action_type == "updateLight":
588
- toast_message = "Light updated"
589
- elif action_type == "setBackground":
590
- toast_message = "Background updated"
591
- elif action_type == "setFog":
592
- toast_message = "Fog updated"
593
- # Player tool toast messages
594
- elif action_type == "setPlayerSpeed":
595
- speed = action_result["data"].get("walk_speed", 5)
596
- toast_message = f"Player speed: {speed} m/s"
597
- elif action_type == "setJumpForce":
598
- force = action_result["data"].get("jump_force", 5)
599
- toast_message = f"Jump force: {force} m/s"
600
- elif action_type == "setGravity":
601
- gravity = action_result["data"].get("gravity", -9.82)
602
- toast_message = f"Gravity: {gravity} m/s²"
603
- elif action_type == "setCameraFov":
604
- fov = action_result["data"].get("fov", 75)
605
- toast_message = f"Camera FOV: {fov}°"
606
- elif action_type == "setMouseSensitivity":
607
- sens = action_result["data"].get("sensitivity", 0.002)
608
- toast_message = f"Mouse sensitivity: {sens}"
609
- elif action_type == "setPlayerDimensions":
610
- height = action_result["data"].get("height", 1.7)
611
- toast_message = f"Player height: {height}m"
612
- # Environment tool toast messages
613
- elif action_type == "addSkybox":
614
- preset = action_result["data"].get("preset", "custom")
615
- toast_message = f"Skybox added: {preset}"
616
- elif action_type == "removeSkybox":
617
- toast_message = "Skybox removed"
618
- elif action_type == "addParticles":
619
- preset = action_result["data"].get("preset", "effect")
620
- toast_message = f"Particles added: {preset}"
621
- elif action_type == "removeParticles":
622
- toast_message = "Particles removed"
623
- # UI tool toast messages
624
- elif action_type == "renderText":
625
- text = action_result["data"].get("text", "")[:20]
626
- toast_message = f"Text rendered: {text}..."
627
- elif action_type == "renderBar":
628
- label = action_result["data"].get("label", "Bar")
629
- toast_message = f"Bar rendered: {label}"
630
- elif action_type == "removeUIElement":
631
- toast_message = "UI element removed"
632
- # Toon shading toast
633
- elif action_type == "updateToonMaterial":
634
- enabled = action_result["data"].get("enabled", True)
635
- toast_message = "Toon shading " + ("enabled" if enabled else "disabled")
636
- # Brick toast
637
- elif action_type == "addBrick":
638
- brick_type = action_result["data"].get("brick_type", "brick")
639
- toast_message = f"Added {brick_type.replace('_', ' ')}"
640
-
641
- # Create JSON payload for the .then() JavaScript handler
642
- # Include timestamp to ensure Gradio detects change even for repeated actions
643
  action_json = json.dumps({
644
  "action": action_result["action"],
645
  "data": action_result["data"],
@@ -689,7 +622,7 @@ with gr.Blocks(title="GCP - Game Context Protocol") as demo:
689
  queue=False
690
  ).then(
691
  bot,
692
- [chatbot, crosshair_pos],
693
  [chatbot, viewer, action_data]
694
  )
695
 
 
1
  """
2
  GCP - Game Context Protocol
3
+ Build 3D game scenes with natural language using AI.
4
+
5
+ Architecture:
6
+ - Gradio: Chat interface and static file serving
7
+ - FastAPI: Scene API and MCP tools (local dev only)
8
+ - Three.js: 3D rendering in embedded iframe
9
+ - OpenAI: Natural language processing with function calling
10
  """
11
  import os
12
  import json
 
 
 
13
  import time
14
+ import gradio as gr
15
+
16
+ # Static file serving for 3D models (GLB files)
17
+ # Accessible at /gradio_api/file=models/<path>
18
+ gr.set_static_paths(paths=["models"])
19
+
20
+ # =============================================================================
21
+ # Environment Configuration
22
+ # =============================================================================
 
 
 
23
  IS_HF_SPACES = bool(os.getenv("SPACE_ID"))
24
+ FASTAPI_INTERNAL = "http://localhost:8000"
25
+ FASTAPI_URL = "" # Relative URLs for HF Spaces
26
+
27
  if IS_HF_SPACES:
28
+ print(f"🌐 HF Spaces: {os.getenv('SPACE_ID')}")
 
 
 
 
 
 
 
 
29
  else:
30
+ # Local dev: FastAPI on 8000, Gradio on 7860
 
31
  FASTAPI_URL = os.getenv("FASTAPI_URL", "http://localhost:8000")
32
  FASTAPI_INTERNAL = FASTAPI_URL
33
+ print(f"💻 Local dev: {FASTAPI_URL}")
 
34
 
35
+ # Global state
36
  current_scene_id = None
37
+ current_provider = "openai" # "openai" or "gemini"
38
+
39
+
40
+ def _get_toast_message(action_type: str, data: dict) -> str:
41
+ """Generate user-friendly toast message for an action."""
42
+ # Simple static messages
43
+ STATIC_MESSAGES = {
44
+ "removeObject": "Object removed",
45
+ "setLighting": "Lighting updated",
46
+ "updateMaterial": "Material updated",
47
+ "removeLight": "Light removed",
48
+ "updateLight": "Light updated",
49
+ "setBackground": "Background updated",
50
+ "setFog": "Fog updated",
51
+ "removeSkybox": "Skybox removed",
52
+ "removeParticles": "Particles removed",
53
+ "removeUIElement": "UI element removed",
54
+ }
55
+ if action_type in STATIC_MESSAGES:
56
+ return STATIC_MESSAGES[action_type]
57
+
58
+ # Dynamic messages with data
59
+ if action_type == "addObject":
60
+ return f"Added {data.get('type', 'object')}"
61
+ if action_type == "setControlMode":
62
+ return f"Switched to {data.get('mode', '').upper()} mode"
63
+ if action_type == "addLight":
64
+ return f"Added light: {data.get('name', 'Light')}"
65
+ if action_type == "setPlayerSpeed":
66
+ return f"Speed: {data.get('walk_speed', 5)} m/s"
67
+ if action_type == "setJumpForce":
68
+ return f"Jump: {data.get('jump_force', 5)} m/s"
69
+ if action_type == "setGravity":
70
+ return f"Gravity: {data.get('gravity', -9.82)} m/s²"
71
+ if action_type == "setCameraFov":
72
+ return f"FOV: {data.get('fov', 75)}°"
73
+ if action_type == "setMouseSensitivity":
74
+ return f"Sensitivity: {data.get('sensitivity', 0.002)}"
75
+ if action_type == "setPlayerDimensions":
76
+ return f"Height: {data.get('height', 1.7)}m"
77
+ if action_type == "addSkybox":
78
+ return f"Skybox: {data.get('preset', 'custom')}"
79
+ if action_type == "addParticles":
80
+ return f"Particles: {data.get('preset', 'effect')}"
81
+ if action_type == "renderText":
82
+ return f"Text: {data.get('text', '')[:15]}..."
83
+ if action_type == "renderBar":
84
+ return f"Bar: {data.get('label', 'Bar')}"
85
+ if action_type == "updateToonMaterial":
86
+ return "Toon " + ("on" if data.get("enabled", True) else "off")
87
+ if action_type == "addBrick":
88
+ return f"Added {data.get('brick_type', 'brick').replace('_', ' ')}"
89
+
90
+ return action_type # Fallback to action name
91
+
92
+
93
+ # =============================================================================
94
+ # FastAPI Server (Local Dev Only)
95
+ # =============================================================================
96
  if not IS_HF_SPACES:
97
+ import threading
98
+ import uvicorn
99
+ import requests
100
+ from backend.main import app as fastapi_app
101
+
102
+ def _wait_for_fastapi():
103
+ """Wait for FastAPI health check."""
104
+ print("⏳ Waiting for FastAPI...")
105
+ for _ in range(30):
106
+ try:
107
+ if requests.get(f"{FASTAPI_INTERNAL}/health", timeout=2).ok:
108
+ print("✅ FastAPI ready")
109
+ return
110
+ except:
111
+ time.sleep(1)
112
+ print("⚠️ FastAPI timeout")
113
 
114
+ threading.Thread(
115
+ target=lambda: uvicorn.run(fastapi_app, host="0.0.0.0", port=8000, log_level="warning"),
116
+ daemon=True
117
+ ).start()
118
+ _wait_for_fastapi()
119
 
120
 
121
  def get_viewer_html(scene_id="welcome"):
 
162
  return None
163
 
164
 
165
+ # Initialize the chat client
166
+ chat_client = None
167
 
168
+ def get_chat_client(provider: str = "openai"):
169
+ """Get or create the chat client with specified provider"""
170
+ global chat_client, current_scene_id, current_provider
171
+ # Recreate client if scene or provider changed
172
+ if chat_client is None or chat_client.scene_id != current_scene_id or chat_client.provider != provider:
173
  from chat_client import GCPChatClient
174
+ chat_client = GCPChatClient(scene_id=current_scene_id, base_url=FASTAPI_URL, provider=provider)
175
+ current_provider = provider
176
+ return chat_client
177
 
178
 
179
+ def chat_response(message, history, crosshair_position=None, provider="openai"):
180
+ """Handle chat messages using LLM with tool calling"""
181
  global current_scene_id
182
 
183
  # Handle help command locally (no need for LLM)
 
205
  - Press C in viewer to toggle FPS/Orbit camera
206
  - WASD to move, Space to jump in FPS mode
207
  - Click in viewer to enable mouse-look
208
+
209
+ **LLM Provider:** Currently using {provider.upper()}
210
+ """.format(provider=provider), None
211
 
212
  try:
213
+ client = get_chat_client(provider)
214
  # Pass crosshair position to chat client for context-aware object placement
215
  response, action_data = client.chat(message, crosshair_position=crosshair_position)
216
  return response, action_data
 
419
  with gr.Column(elem_id="chat-column", scale=1, min_width=350):
420
  gr.Markdown("### 🎮 GCP - Game Context Protocol")
421
  chatbot = gr.Chatbot(
422
+ height=450, # Slightly shorter to make room for provider dropdown
423
  show_label=False,
424
  elem_id="chatbot",
 
 
 
 
 
 
 
425
  )
426
+ with gr.Row():
427
+ msg = gr.Textbox(
428
+ placeholder="'add a red cube' • 'set lighting to night' • 'help'",
429
+ show_label=False,
430
+ container=False,
431
+ elem_id="chat-input",
432
+ scale=4
433
+ )
434
+ provider_dropdown = gr.Dropdown(
435
+ choices=["openai", "gemini"],
436
+ value="openai",
437
+ label="LLM",
438
+ scale=1,
439
+ min_width=100
440
+ )
441
 
442
  # Right column: 3D Viewer (scale=3 = ~75% width)
443
  with gr.Column(elem_id="viewer-column", scale=3):
 
532
  history.append({"role": "user", "content": user_message})
533
  return "", history
534
 
535
+ def bot(history, crosshair_position, provider):
536
+ """Generate bot response using selected LLM provider"""
537
  # Gradio 6: content can be a string or list of content blocks
538
  content = history[-1]["content"]
539
  if isinstance(content, list):
 
553
  except:
554
  pass
555
 
556
+ # Process command with crosshair context and selected provider
557
+ bot_message, action_result = chat_response(user_message, [], crosshair_pos_dict, provider)
558
  history.append({"role": "assistant", "content": bot_message})
559
 
560
  # Handle action_result
 
568
  # Full reload: update iframe src
569
  viewer_html = f'<div id="viewer-container" style="width:100%; min-height:500px; height:70vh;"><iframe src="{action_result["url"]}" style="width:100%; height:100%; border:none;"></iframe></div>'
570
 
571
+ else:
572
+ # Generate toast message for the action
573
+ toast_message = _get_toast_message(action_type, action_result.get("data", {}))
574
+
575
+ # Send action to viewer via postMessage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  action_json = json.dumps({
577
  "action": action_result["action"],
578
  "data": action_result["data"],
 
622
  queue=False
623
  ).then(
624
  bot,
625
+ [chatbot, crosshair_pos, provider_dropdown],
626
  [chatbot, viewer, action_data]
627
  )
628
 
chat_client.py CHANGED
@@ -1,12 +1,16 @@
1
  """
2
- GPT-powered Chat Client for GCP (Game Context Protocol)
3
 
4
- This module provides an intelligent chat interface that uses OpenAI's GPT
5
- with function calling to interact with the GCP tools.
 
 
 
 
6
  """
7
  import os
8
  import json
9
- from typing import Optional, Dict, Any, List
10
 
11
  # Load .env file if present
12
  from dotenv import load_dotenv
@@ -14,6 +18,16 @@ load_dotenv()
14
 
15
  from openai import OpenAI
16
 
 
 
 
 
 
 
 
 
 
 
17
  # Import GCP tools
18
  from backend.tools.scene_tools import (
19
  create_game_scene,
@@ -542,15 +556,85 @@ TOOLS = [
542
  ]
543
 
544
 
545
- class GCPChatClient:
546
- """GPT-powered chat client for GCP"""
 
 
547
 
548
- def __init__(self, scene_id: str, base_url: str = "http://localhost:8000"):
549
- self.client = OpenAI() # Uses OPENAI_API_KEY env var
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  self.scene_id = scene_id
551
  self.base_url = base_url
 
552
  self.conversation_history: List[Dict[str, Any]] = []
553
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  # System prompt
555
  self.system_prompt = f"""You are a helpful assistant for GCP (Game Context Protocol), a 3D scene building system.
556
 
@@ -841,12 +925,20 @@ The y coordinate should be adjusted based on object size (e.g., y=0.5 for a cube
841
 
842
  If the user DOES specify a position (e.g., "add a cube at 0, 0, 0"), use their specified position instead."""
843
 
844
- # Build messages with system prompt
845
- messages = [{"role": "system", "content": system_prompt}] + self.conversation_history
846
-
847
  # Track actions for frontend
848
  actions = []
849
 
 
 
 
 
 
 
 
 
 
 
 
850
  # Call GPT with tools
851
  while True:
852
  response = self.client.chat.completions.create(
@@ -919,6 +1011,72 @@ If the user DOES specify a position (e.g., "add a cube at 0, 0, 0"), use their s
919
 
920
  return final_response, action_data
921
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
922
  def _build_frontend_action(self, action: Dict[str, Any]) -> Optional[Dict[str, Any]]:
923
  """Convert tool result to frontend action"""
924
  tool = action["tool"]
@@ -1034,6 +1192,10 @@ If the user DOES specify a position (e.g., "add a cube at 0, 0, 0"), use their s
1034
 
1035
 
1036
  # Convenience function for simple usage
1037
- def create_chat_client(scene_id: str = "welcome", base_url: str = "http://localhost:8000") -> GCPChatClient:
1038
- """Create a new GCP chat client"""
1039
- return GCPChatClient(scene_id, base_url)
 
 
 
 
 
1
  """
2
+ Multi-LLM Chat Client for GCP (Game Context Protocol)
3
 
4
+ This module provides an intelligent chat interface that uses either OpenAI GPT
5
+ or Google Gemini with function calling to interact with the GCP tools.
6
+
7
+ Supports:
8
+ - OpenAI GPT-4o-mini (default)
9
+ - Google Gemini 2.0 Flash
10
  """
11
  import os
12
  import json
13
+ from typing import Optional, Dict, Any, List, Literal
14
 
15
  # Load .env file if present
16
  from dotenv import load_dotenv
 
18
 
19
  from openai import OpenAI
20
 
21
+ # Gemini import (optional - may not be installed)
22
+ try:
23
+ import google.generativeai as genai
24
+ GEMINI_AVAILABLE = True
25
+ except ImportError:
26
+ GEMINI_AVAILABLE = False
27
+ genai = None
28
+
29
+ LLMProvider = Literal["openai", "gemini"]
30
+
31
  # Import GCP tools
32
  from backend.tools.scene_tools import (
33
  create_game_scene,
 
556
  ]
557
 
558
 
559
+ def _convert_schema_for_gemini(schema: Dict) -> Dict:
560
+ """Convert OpenAI JSON schema to Gemini format."""
561
+ if not schema:
562
+ return {}
563
 
564
+ result = {}
565
+
566
+ # Convert type
567
+ if "type" in schema:
568
+ type_map = {
569
+ "object": "OBJECT",
570
+ "string": "STRING",
571
+ "number": "NUMBER",
572
+ "integer": "INTEGER",
573
+ "boolean": "BOOLEAN",
574
+ "array": "ARRAY"
575
+ }
576
+ result["type"] = type_map.get(schema["type"], "STRING")
577
+
578
+ # Convert properties
579
+ if "properties" in schema:
580
+ result["properties"] = {}
581
+ for key, val in schema["properties"].items():
582
+ result["properties"][key] = _convert_schema_for_gemini(val)
583
+
584
+ # Copy other fields
585
+ if "description" in schema:
586
+ result["description"] = schema["description"]
587
+ if "enum" in schema:
588
+ result["enum"] = schema["enum"]
589
+ if "required" in schema:
590
+ result["required"] = schema["required"]
591
+ if "items" in schema:
592
+ result["items"] = _convert_schema_for_gemini(schema["items"])
593
+
594
+ return result
595
+
596
+
597
+ def _convert_tools_to_gemini() -> List[Dict]:
598
+ """Convert OpenAI tool format to Gemini function declarations."""
599
+ gemini_tools = []
600
+ for tool in TOOLS:
601
+ func = tool["function"]
602
+ gemini_tools.append({
603
+ "name": func["name"],
604
+ "description": func["description"],
605
+ "parameters": _convert_schema_for_gemini(func["parameters"])
606
+ })
607
+ return gemini_tools
608
+
609
+
610
+ class GCPChatClient:
611
+ """Multi-LLM chat client for GCP - supports OpenAI and Gemini"""
612
+
613
+ def __init__(
614
+ self,
615
+ scene_id: str,
616
+ base_url: str = "http://localhost:8000",
617
+ provider: LLMProvider = "openai"
618
+ ):
619
  self.scene_id = scene_id
620
  self.base_url = base_url
621
+ self.provider = provider
622
  self.conversation_history: List[Dict[str, Any]] = []
623
 
624
+ # Initialize the appropriate client
625
+ if provider == "gemini":
626
+ if not GEMINI_AVAILABLE:
627
+ raise ImportError("google-generativeai not installed. Run: pip install google-generativeai")
628
+ genai.configure(api_key=os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY"))
629
+ self.gemini_model = genai.GenerativeModel(
630
+ model_name="gemini-2.0-flash",
631
+ tools=_convert_tools_to_gemini()
632
+ )
633
+ self.client = None
634
+ else:
635
+ self.client = OpenAI() # Uses OPENAI_API_KEY env var
636
+ self.gemini_model = None
637
+
638
  # System prompt
639
  self.system_prompt = f"""You are a helpful assistant for GCP (Game Context Protocol), a 3D scene building system.
640
 
 
925
 
926
  If the user DOES specify a position (e.g., "add a cube at 0, 0, 0"), use their specified position instead."""
927
 
 
 
 
928
  # Track actions for frontend
929
  actions = []
930
 
931
+ # Route to appropriate provider
932
+ if self.provider == "gemini":
933
+ return self._chat_gemini(user_message, system_prompt, actions)
934
+ else:
935
+ return self._chat_openai(user_message, system_prompt, actions)
936
+
937
+ def _chat_openai(self, user_message: str, system_prompt: str, actions: List) -> tuple[str, Optional[Dict[str, Any]]]:
938
+ """Handle chat with OpenAI GPT"""
939
+ # Build messages with system prompt
940
+ messages = [{"role": "system", "content": system_prompt}] + self.conversation_history
941
+
942
  # Call GPT with tools
943
  while True:
944
  response = self.client.chat.completions.create(
 
1011
 
1012
  return final_response, action_data
1013
 
1014
+ def _chat_gemini(self, user_message: str, system_prompt: str, actions: List) -> tuple[str, Optional[Dict[str, Any]]]:
1015
+ """Handle chat with Google Gemini"""
1016
+ # Start a chat session with Gemini
1017
+ chat = self.gemini_model.start_chat(history=[])
1018
+
1019
+ # Combine system prompt with user message for first turn
1020
+ full_prompt = f"{system_prompt}\n\nUser: {user_message}"
1021
+
1022
+ while True:
1023
+ response = chat.send_message(full_prompt)
1024
+
1025
+ # Check for function calls
1026
+ function_calls = []
1027
+ for part in response.parts:
1028
+ if hasattr(part, 'function_call') and part.function_call:
1029
+ function_calls.append(part.function_call)
1030
+
1031
+ if function_calls:
1032
+ # Execute each function call
1033
+ function_responses = []
1034
+ for fc in function_calls:
1035
+ function_name = fc.name
1036
+ function_args = dict(fc.args)
1037
+
1038
+ # Execute the tool
1039
+ try:
1040
+ result = self.execute_tool(function_name, function_args)
1041
+ actions.append({
1042
+ "tool": function_name,
1043
+ "args": function_args,
1044
+ "result": result
1045
+ })
1046
+ function_responses.append(genai.protos.Part(
1047
+ function_response=genai.protos.FunctionResponse(
1048
+ name=function_name,
1049
+ response={"result": result}
1050
+ )
1051
+ ))
1052
+ except Exception as e:
1053
+ function_responses.append(genai.protos.Part(
1054
+ function_response=genai.protos.FunctionResponse(
1055
+ name=function_name,
1056
+ response={"error": str(e)}
1057
+ )
1058
+ ))
1059
+
1060
+ # Send function results back to Gemini
1061
+ full_prompt = function_responses
1062
+ else:
1063
+ # No function calls, extract text response
1064
+ final_response = response.text or "Done!"
1065
+
1066
+ # Add to conversation history
1067
+ self.conversation_history.append({
1068
+ "role": "assistant",
1069
+ "content": final_response
1070
+ })
1071
+
1072
+ # Build action data for frontend
1073
+ action_data = None
1074
+ if actions:
1075
+ last_action = actions[-1]
1076
+ action_data = self._build_frontend_action(last_action)
1077
+
1078
+ return final_response, action_data
1079
+
1080
  def _build_frontend_action(self, action: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1081
  """Convert tool result to frontend action"""
1082
  tool = action["tool"]
 
1192
 
1193
 
1194
  # Convenience function for simple usage
1195
+ def create_chat_client(
1196
+ scene_id: str = "welcome",
1197
+ base_url: str = "http://localhost:8000",
1198
+ provider: LLMProvider = "openai"
1199
+ ) -> GCPChatClient:
1200
+ """Create a new GCP chat client with specified LLM provider"""
1201
+ return GCPChatClient(scene_id, base_url, provider)
requirements.txt CHANGED
@@ -11,4 +11,5 @@ gradio
11
 
12
  # LLM
13
  openai
 
14
  python-dotenv
 
11
 
12
  # LLM
13
  openai
14
+ google-generativeai
15
  python-dotenv