| |
| |
| |
| |
| |
|
|
| """ |
| Web interface for OpenEnv environments. |
| |
| This module provides a web-based interface for interacting with OpenEnv environments, |
| including a two-pane layout for HumanAgent interaction and state observation. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import time |
| from dataclasses import asdict, dataclass |
| from typing import Any, Dict, List, Optional, Type |
| from datetime import datetime |
|
|
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request |
| from fastapi.responses import HTMLResponse, FileResponse |
| from fastapi.staticfiles import StaticFiles |
| from pydantic import BaseModel |
|
|
| from .interfaces import Environment |
| from .types import Action, Observation, State, EnvironmentMetadata |
|
|
|
|
| def load_environment_metadata(env: Environment, env_name: Optional[str] = None) -> EnvironmentMetadata: |
| """ |
| Load environment metadata including README content. |
| |
| Args: |
| env: The environment instance |
| env_name: Optional environment name for README file lookup |
| |
| Returns: |
| EnvironmentMetadata with loaded information |
| """ |
| |
| if hasattr(env, 'get_metadata'): |
| return env.get_metadata() |
| |
| |
| metadata = EnvironmentMetadata( |
| name=env_name or env.__class__.__name__, |
| description=f"{env.__class__.__name__} environment", |
| version="1.0.0" |
| ) |
| |
| |
| readme_content = _load_readme_from_filesystem(env_name) |
| if readme_content: |
| metadata.readme_content = readme_content |
| |
| return metadata |
|
|
|
|
| def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]: |
| """ |
| Load README content from the filesystem. |
| |
| Tries multiple locations: |
| 1. Container filesystem: /app/README.md |
| 2. Local development: src/envs/{env_name}/README.md |
| 3. Environment variable: ENV_README_PATH |
| """ |
| import os |
| from pathlib import Path |
| |
| |
| container_readme = Path("/app/README.md") |
| if container_readme.exists(): |
| try: |
| return container_readme.read_text(encoding='utf-8') |
| except Exception: |
| pass |
| |
| |
| custom_path = os.environ.get("ENV_README_PATH") |
| if custom_path and Path(custom_path).exists(): |
| try: |
| return Path(custom_path).read_text(encoding='utf-8') |
| except Exception: |
| pass |
| |
| |
| if env_name: |
| local_readme = Path(f"src/envs/{env_name}/README.md") |
| if local_readme.exists(): |
| try: |
| return local_readme.read_text(encoding='utf-8') |
| except Exception: |
| pass |
| |
| return None |
|
|
|
|
| @dataclass |
| class ActionLog: |
| """Log entry for an action taken.""" |
| timestamp: str |
| action: Dict[str, Any] |
| observation: Dict[str, Any] |
| reward: Optional[float] |
| done: bool |
| step_count: int |
|
|
|
|
| @dataclass |
| class EpisodeState: |
| """Current episode state for the web interface.""" |
| episode_id: Optional[str] |
| step_count: int |
| current_observation: Optional[Dict[str, Any]] |
| action_logs: List[ActionLog] |
| is_reset: bool = True |
|
|
|
|
| class WebInterfaceManager: |
| """Manages the web interface for an environment.""" |
| |
| def __init__( |
| self, |
| env: Environment, |
| action_cls: Type[Action], |
| observation_cls: Type[Observation], |
| metadata: Optional[EnvironmentMetadata] = None, |
| ): |
| self.env = env |
| self.action_cls = action_cls |
| self.observation_cls = observation_cls |
| self.metadata = metadata or EnvironmentMetadata( |
| name=env.__class__.__name__, |
| description=f"{env.__class__.__name__} environment" |
| ) |
| self.episode_state = EpisodeState( |
| episode_id=None, |
| step_count=0, |
| current_observation=None, |
| action_logs=[] |
| ) |
| self.connected_clients: List[WebSocket] = [] |
| |
| async def connect_websocket(self, websocket: WebSocket): |
| """Connect a new WebSocket client.""" |
| await websocket.accept() |
| self.connected_clients.append(websocket) |
| |
| |
| await self._send_state_update() |
| |
| async def disconnect_websocket(self, websocket: WebSocket): |
| """Disconnect a WebSocket client.""" |
| if websocket in self.connected_clients: |
| self.connected_clients.remove(websocket) |
| |
| async def _send_state_update(self): |
| """Send current state to all connected clients.""" |
| if not self.connected_clients: |
| return |
| |
| state_data = { |
| "type": "state_update", |
| "episode_state": asdict(self.episode_state) |
| } |
| |
| |
| disconnected_clients = [] |
| for client in self.connected_clients: |
| try: |
| await client.send_text(json.dumps(state_data)) |
| except: |
| disconnected_clients.append(client) |
| |
| |
| for client in disconnected_clients: |
| self.connected_clients.remove(client) |
| |
| async def reset_environment(self) -> Dict[str, Any]: |
| """Reset the environment and update state.""" |
| observation = self.env.reset() |
| state = self.env.state |
| |
| |
| self.episode_state.episode_id = state.episode_id |
| self.episode_state.step_count = 0 |
| self.episode_state.current_observation = asdict(observation) |
| self.episode_state.action_logs = [] |
| self.episode_state.is_reset = True |
| |
| |
| await self._send_state_update() |
| |
| return { |
| "observation": asdict(observation), |
| "reward": observation.reward, |
| "done": observation.done, |
| } |
| |
| async def step_environment(self, action_data: Dict[str, Any]) -> Dict[str, Any]: |
| """Execute a step in the environment and update state.""" |
| |
| action = self._deserialize_action(action_data) |
| |
| |
| observation = self.env.step(action) |
| state = self.env.state |
| |
| |
| action_log = ActionLog( |
| timestamp=datetime.now().isoformat(), |
| action=asdict(action), |
| observation=asdict(observation), |
| reward=observation.reward, |
| done=observation.done, |
| step_count=state.step_count |
| ) |
| |
| |
| self.episode_state.episode_id = state.episode_id |
| self.episode_state.step_count = state.step_count |
| self.episode_state.current_observation = asdict(observation) |
| self.episode_state.action_logs.append(action_log) |
| self.episode_state.is_reset = False |
| |
| |
| await self._send_state_update() |
| |
| return { |
| "observation": asdict(observation), |
| "reward": observation.reward, |
| "done": observation.done, |
| } |
| |
| def get_state(self) -> Dict[str, Any]: |
| """Get current environment state.""" |
| state = self.env.state |
| return asdict(state) |
| |
| def _deserialize_action(self, action_data: Dict[str, Any]) -> Action: |
| """Convert JSON dict to Action instance.""" |
| metadata = action_data.pop("metadata", {}) |
| |
| |
| processed_data = {} |
| for key, value in action_data.items(): |
| if key == "tokens" and isinstance(value, (list, str)): |
| |
| if isinstance(value, str): |
| |
| try: |
| import json |
| value = json.loads(value) |
| except: |
| |
| value = [] |
| if isinstance(value, list): |
| import torch |
| processed_data[key] = torch.tensor(value, dtype=torch.long) |
| else: |
| processed_data[key] = value |
| elif key == "action_id" and isinstance(value, str): |
| |
| try: |
| processed_data[key] = int(value) |
| except ValueError: |
| |
| processed_data[key] = value |
| else: |
| processed_data[key] = value |
| |
| action = self.action_cls(**processed_data) |
| action.metadata = metadata |
| return action |
|
|
|
|
| def create_web_interface_app( |
| env: Environment, |
| action_cls: Type[Action], |
| observation_cls: Type[Observation], |
| env_name: Optional[str] = None, |
| ) -> FastAPI: |
| """ |
| Create a FastAPI application with web interface for the given environment. |
| |
| Args: |
| env: The Environment instance to serve |
| action_cls: The Action subclass this environment expects |
| observation_cls: The Observation subclass this environment returns |
| env_name: Optional environment name for README loading |
| |
| Returns: |
| FastAPI application instance with web interface |
| """ |
| from .http_server import create_fastapi_app |
| |
| |
| app = create_fastapi_app(env, action_cls, observation_cls) |
| |
| |
| metadata = load_environment_metadata(env, env_name) |
| |
| |
| web_manager = WebInterfaceManager(env, action_cls, observation_cls, metadata) |
| |
| |
| @app.get("/web", response_class=HTMLResponse) |
| async def web_interface(): |
| """Serve the web interface.""" |
| return get_web_interface_html(action_cls, web_manager.metadata) |
| |
| @app.get("/web/metadata") |
| async def web_metadata(): |
| """Get environment metadata.""" |
| return asdict(web_manager.metadata) |
| |
| @app.websocket("/ws") |
| async def websocket_endpoint(websocket: WebSocket): |
| """WebSocket endpoint for real-time updates.""" |
| await web_manager.connect_websocket(websocket) |
| try: |
| while True: |
| |
| await websocket.receive_text() |
| except WebSocketDisconnect: |
| await web_manager.disconnect_websocket(websocket) |
| |
| @app.post("/web/reset") |
| async def web_reset(): |
| """Reset endpoint for web interface.""" |
| return await web_manager.reset_environment() |
| |
| @app.post("/web/step") |
| async def web_step(request: Dict[str, Any]): |
| """Step endpoint for web interface.""" |
| |
| if "message" in request: |
| message = request["message"] |
| |
| action = web_manager.env.message_to_action(message) |
| action_data = {"tokens": action.tokens.tolist()} |
| else: |
| action_data = request.get("action", {}) |
| |
| return await web_manager.step_environment(action_data) |
| |
| @app.get("/web/state") |
| async def web_state(): |
| """State endpoint for web interface.""" |
| return web_manager.get_state() |
| |
| return app |
|
|
|
|
| def get_web_interface_html(action_cls: Type[Action], metadata: Optional[EnvironmentMetadata] = None) -> str: |
| """Generate the HTML for the web interface.""" |
| |
| |
| is_chat_env = False |
| if hasattr(action_cls, '__dataclass_fields__'): |
| for field_name, field_info in action_cls.__dataclass_fields__.items(): |
| if field_name == 'tokens' and hasattr(field_info.type, '__name__') and 'Tensor' in field_info.type.__name__: |
| is_chat_env = True |
| break |
| |
| |
| action_fields = _extract_action_fields(action_cls) |
| |
| return f""" |
| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>OpenEnv Web Interface</title> |
| <style> |
| * {{ |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| }} |
| |
| body {{ |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| background-color: #f5f5f5; |
| height: 100vh; |
| overflow: hidden; |
| }} |
| |
| .container {{ |
| display: flex; |
| height: 100vh; |
| }} |
| |
| .left-pane {{ |
| width: 50%; |
| background: white; |
| border-right: 1px solid #e0e0e0; |
| display: flex; |
| flex-direction: column; |
| }} |
| |
| .right-pane {{ |
| width: 50%; |
| background: #fafafa; |
| display: flex; |
| flex-direction: column; |
| }} |
| |
| .pane-header {{ |
| padding: 20px; |
| border-bottom: 1px solid #e0e0e0; |
| background: #f8f9fa; |
| font-weight: 600; |
| font-size: 16px; |
| }} |
| |
| .pane-content {{ |
| flex: 1; |
| padding: 20px; |
| overflow-y: auto; |
| }} |
| |
| .action-form {{ |
| background: white; |
| border: 1px solid #e0e0e0; |
| border-radius: 8px; |
| padding: 20px; |
| margin-bottom: 20px; |
| }} |
| |
| .form-group {{ |
| margin-bottom: 15px; |
| }} |
| |
| .form-group label {{ |
| display: block; |
| margin-bottom: 5px; |
| font-weight: 500; |
| color: #333; |
| }} |
| |
| .form-group input, .form-group textarea {{ |
| width: 100%; |
| padding: 8px 12px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| font-size: 14px; |
| }} |
| |
| .form-group input:focus, .form-group textarea:focus {{ |
| outline: none; |
| border-color: #007bff; |
| box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| }} |
| |
| .btn {{ |
| background: #007bff; |
| color: white; |
| border: none; |
| padding: 10px 20px; |
| border-radius: 4px; |
| cursor: pointer; |
| font-size: 14px; |
| margin-right: 10px; |
| margin-bottom: 10px; |
| }} |
| |
| .btn:hover {{ |
| background: #0056b3; |
| }} |
| |
| .btn:disabled {{ |
| background: #6c757d; |
| cursor: not-allowed; |
| }} |
| |
| .btn-secondary {{ |
| background: #6c757d; |
| }} |
| |
| .btn-secondary:hover {{ |
| background: #545b62; |
| }} |
| |
| .state-display {{ |
| background: white; |
| border: 1px solid #e0e0e0; |
| border-radius: 8px; |
| padding: 15px; |
| margin-bottom: 20px; |
| }} |
| |
| .state-item {{ |
| margin-bottom: 8px; |
| }} |
| |
| .state-label {{ |
| font-weight: 500; |
| color: #666; |
| }} |
| |
| .state-value {{ |
| color: #333; |
| font-family: monospace; |
| }} |
| |
| .logs-container {{ |
| background: white; |
| border: 1px solid #e0e0e0; |
| border-radius: 8px; |
| padding: 15px; |
| max-height: 400px; |
| overflow-y: auto; |
| }} |
| |
| .log-entry {{ |
| border-bottom: 1px solid #f0f0f0; |
| padding: 10px 0; |
| }} |
| |
| .log-entry:last-child {{ |
| border-bottom: none; |
| }} |
| |
| .log-timestamp {{ |
| font-size: 12px; |
| color: #666; |
| margin-bottom: 5px; |
| }} |
| |
| .log-action {{ |
| background: #e3f2fd; |
| padding: 8px; |
| border-radius: 4px; |
| margin-bottom: 5px; |
| font-family: monospace; |
| font-size: 12px; |
| }} |
| |
| .log-observation {{ |
| background: #f3e5f5; |
| padding: 8px; |
| border-radius: 4px; |
| font-family: monospace; |
| font-size: 12px; |
| }} |
| |
| .log-reward {{ |
| font-weight: 600; |
| color: #28a745; |
| }} |
| |
| .log-done {{ |
| font-weight: 600; |
| color: #dc3545; |
| }} |
| |
| .status-indicator {{ |
| display: inline-block; |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| margin-right: 8px; |
| }} |
| |
| .status-connected {{ |
| background: #28a745; |
| }} |
| |
| .status-disconnected {{ |
| background: #dc3545; |
| }} |
| |
| .json-display {{ |
| background: #f8f9fa; |
| border: 1px solid #e9ecef; |
| border-radius: 4px; |
| padding: 10px; |
| font-family: monospace; |
| font-size: 12px; |
| white-space: pre-wrap; |
| max-height: 200px; |
| overflow-y: auto; |
| }} |
| |
| /* Chat Interface Styles */ |
| .chat-interface {{ |
| background: white; |
| border: 1px solid #e0e0e0; |
| border-radius: 8px; |
| padding: 20px; |
| margin-bottom: 20px; |
| }} |
| |
| .chat-messages {{ |
| background: #f8f9fa; |
| border: 1px solid #e0e0e0; |
| border-radius: 8px; |
| padding: 15px; |
| margin-bottom: 15px; |
| max-height: 400px; |
| overflow-y: auto; |
| }} |
| |
| .chat-message {{ |
| margin-bottom: 15px; |
| padding: 10px; |
| border-radius: 8px; |
| }} |
| |
| .chat-message:last-child {{ |
| margin-bottom: 0; |
| }} |
| |
| .chat-message.user {{ |
| background: #e3f2fd; |
| margin-left: 20px; |
| }} |
| |
| .chat-message.assistant {{ |
| background: #f3e5f5; |
| margin-right: 20px; |
| }} |
| |
| .chat-message.system {{ |
| background: #e8f5e8; |
| font-style: italic; |
| }} |
| |
| .message-role {{ |
| font-weight: 600; |
| font-size: 12px; |
| color: #666; |
| margin-bottom: 5px; |
| }} |
| |
| .message-content {{ |
| font-size: 14px; |
| line-height: 1.4; |
| }} |
| |
| .chat-input-container {{ |
| border-top: 1px solid #e0e0e0; |
| padding-top: 15px; |
| }} |
| |
| .role-selector {{ |
| margin-bottom: 10px; |
| }} |
| |
| .role-selector label {{ |
| font-weight: 500; |
| margin-right: 10px; |
| }} |
| |
| .role-selector select {{ |
| padding: 5px 10px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| }} |
| |
| .message-input {{ |
| display: flex; |
| gap: 10px; |
| align-items: flex-end; |
| }} |
| |
| .message-input textarea {{ |
| flex: 1; |
| padding: 10px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| resize: vertical; |
| font-family: inherit; |
| }} |
| |
| .message-input textarea:focus {{ |
| outline: none; |
| border-color: #007bff; |
| box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| }} |
| |
| /* Instructions Section Styles */ |
| .instructions-section {{ |
| background: white; |
| border: 1px solid #e0e0e0; |
| border-radius: 8px; |
| padding: 20px; |
| margin-bottom: 20px; |
| }} |
| |
| .instructions-header {{ |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 15px; |
| }} |
| |
| .instructions-title {{ |
| font-size: 18px; |
| font-weight: 600; |
| color: #333; |
| margin: 0; |
| }} |
| |
| .instructions-toggle {{ |
| background: #f8f9fa; |
| border: 1px solid #dee2e6; |
| border-radius: 4px; |
| padding: 5px 10px; |
| cursor: pointer; |
| font-size: 12px; |
| color: #6c757d; |
| }} |
| |
| .instructions-toggle:hover {{ |
| background: #e9ecef; |
| }} |
| |
| .instructions-content {{ |
| display: none; |
| max-height: 400px; |
| overflow-y: auto; |
| border-top: 1px solid #e0e0e0; |
| padding-top: 15px; |
| }} |
| |
| .instructions-content.expanded {{ |
| display: block; |
| }} |
| |
| .instructions-content h1, |
| .instructions-content h2, |
| .instructions-content h3 {{ |
| color: #333; |
| margin-top: 20px; |
| margin-bottom: 10px; |
| }} |
| |
| .instructions-content h1 {{ |
| font-size: 24px; |
| border-bottom: 2px solid #007bff; |
| padding-bottom: 10px; |
| }} |
| |
| .instructions-content h2 {{ |
| font-size: 20px; |
| }} |
| |
| .instructions-content h3 {{ |
| font-size: 16px; |
| }} |
| |
| .instructions-content p {{ |
| margin-bottom: 10px; |
| line-height: 1.6; |
| }} |
| |
| .instructions-content code {{ |
| background: #f8f9fa; |
| padding: 2px 4px; |
| border-radius: 3px; |
| font-family: monospace; |
| font-size: 14px; |
| }} |
| |
| .instructions-content pre {{ |
| background: #f8f9fa; |
| border: 1px solid #e9ecef; |
| border-radius: 4px; |
| padding: 15px; |
| overflow-x: auto; |
| margin: 10px 0; |
| }} |
| |
| .instructions-content pre code {{ |
| background: none; |
| padding: 0; |
| }} |
| |
| .instructions-content ul, |
| .instructions-content ol {{ |
| margin: 10px 0; |
| padding-left: 20px; |
| }} |
| |
| .instructions-content li {{ |
| margin-bottom: 5px; |
| }} |
| |
| .instructions-content table {{ |
| border-collapse: collapse; |
| width: 100%; |
| margin: 15px 0; |
| }} |
| |
| .instructions-content th, |
| .instructions-content td {{ |
| border: 1px solid #dee2e6; |
| padding: 8px 12px; |
| text-align: left; |
| }} |
| |
| .instructions-content th {{ |
| background: #f8f9fa; |
| font-weight: 600; |
| }} |
| |
| /* Enhanced Form Styles */ |
| .help-text {{ |
| display: block; |
| margin-top: 5px; |
| font-size: 12px; |
| color: #6c757d; |
| font-style: italic; |
| }} |
| |
| .form-group label {{ |
| font-weight: 500; |
| color: #333; |
| margin-bottom: 5px; |
| }} |
| |
| .form-group select {{ |
| width: 100%; |
| padding: 8px 12px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| font-size: 14px; |
| background-color: white; |
| }} |
| |
| .form-group select:focus {{ |
| outline: none; |
| border-color: #007bff; |
| box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| }} |
| |
| .form-group textarea {{ |
| width: 100%; |
| padding: 8px 12px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| font-size: 14px; |
| font-family: inherit; |
| resize: vertical; |
| }} |
| |
| .form-group textarea:focus {{ |
| outline: none; |
| border-color: #007bff; |
| box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| }} |
| |
| .form-group input[type="number"] {{ |
| width: 100%; |
| padding: 8px 12px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| font-size: 14px; |
| }} |
| |
| .form-group input[type="number"]:focus {{ |
| outline: none; |
| border-color: #007bff; |
| box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| }} |
| |
| .form-group input[type="text"]:focus {{ |
| outline: none; |
| border-color: #007bff; |
| box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); |
| }} |
| |
| .required-indicator {{ |
| color: #dc3545; |
| font-weight: bold; |
| }} |
| |
| .form-group .field-description {{ |
| font-size: 11px; |
| color: #666; |
| margin-top: 2px; |
| font-style: italic; |
| }} |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <!-- Left Pane: HumanAgent Interface --> |
| <div class="left-pane"> |
| <div class="pane-header"> |
| <span class="status-indicator status-disconnected" id="connection-status"></span> |
| HumanAgent Interface |
| </div> |
| <div class="pane-content"> |
| <!-- Instructions Section --> |
| {_generate_instructions_section(metadata)} |
| |
| <!-- Action Form or Chat Interface --> |
| {_generate_action_interface(action_fields, is_chat_env)} |
| |
| <!-- Control Buttons --> |
| <div style="margin-bottom: 20px;"> |
| <button class="btn btn-secondary" id="reset-btn">Reset Environment</button> |
| <button class="btn btn-secondary" id="state-btn">Get State</button> |
| </div> |
| |
| <!-- Current State Display --> |
| <div class="state-display"> |
| <h3>Current State</h3> |
| <div id="current-state"> |
| <div class="state-item"> |
| <span class="state-label">Status:</span> |
| <span class="state-value" id="env-status">Not initialized</span> |
| </div> |
| <div class="state-item"> |
| <span class="state-label">Episode ID:</span> |
| <span class="state-value" id="episode-id">-</span> |
| </div> |
| <div class="state-item"> |
| <span class="state-label">Step Count:</span> |
| <span class="state-value" id="step-count">0</span> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <!-- Right Pane: State Observer --> |
| <div class="right-pane"> |
| <div class="pane-header"> |
| State Observer |
| </div> |
| <div class="pane-content"> |
| <!-- Current Observation --> |
| <div class="state-display"> |
| <h3>Current Observation</h3> |
| <div id="current-observation" class="json-display"> |
| No observation yet |
| </div> |
| </div> |
| |
| <!-- Action Logs --> |
| <div class="logs-container"> |
| <h3>Action History</h3> |
| <div id="action-logs"> |
| No actions taken yet |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| <script> |
| class OpenEnvWebInterface {{ |
| constructor() {{ |
| this.ws = null; |
| this.isConnected = false; |
| this.init(); |
| }} |
| |
| init() {{ |
| this.connectWebSocket(); |
| this.setupEventListeners(); |
| }} |
| |
| connectWebSocket() {{ |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| const wsUrl = `${{protocol}}//${{window.location.host}}/ws`; |
| |
| this.ws = new WebSocket(wsUrl); |
| |
| this.ws.onopen = () => {{ |
| this.isConnected = true; |
| this.updateConnectionStatus(true); |
| console.log('WebSocket connected'); |
| }}; |
| |
| this.ws.onmessage = (event) => {{ |
| const data = JSON.parse(event.data); |
| if (data.type === 'state_update') {{ |
| this.updateUI(data.episode_state); |
| }} |
| }}; |
| |
| this.ws.onclose = () => {{ |
| this.isConnected = false; |
| this.updateConnectionStatus(false); |
| console.log('WebSocket disconnected'); |
| // Attempt to reconnect after 3 seconds |
| setTimeout(() => this.connectWebSocket(), 3000); |
| }}; |
| |
| this.ws.onerror = (error) => {{ |
| console.error('WebSocket error:', error); |
| }}; |
| }} |
| |
| setupEventListeners() {{ |
| // Instructions toggle |
| const instructionsToggle = document.getElementById('instructions-toggle'); |
| const instructionsContent = document.getElementById('instructions-content'); |
| if (instructionsToggle && instructionsContent) {{ |
| instructionsToggle.addEventListener('click', () => {{ |
| instructionsContent.classList.toggle('expanded'); |
| instructionsToggle.textContent = instructionsContent.classList.contains('expanded') |
| ? 'Hide Instructions' : 'Show Instructions'; |
| }}); |
| }} |
| |
| // Check if this is a chat environment |
| const isChatEnv = document.getElementById('chat-messages') !== null; |
| |
| if (isChatEnv) {{ |
| // Chat environment event listeners |
| document.getElementById('send-message-btn').addEventListener('click', () => {{ |
| this.sendMessage(); |
| }}); |
| |
| // Send message on Enter (but allow Shift+Enter for new lines) |
| document.getElementById('message-input').addEventListener('keydown', (e) => {{ |
| if (e.key === 'Enter' && !e.shiftKey) {{ |
| e.preventDefault(); |
| this.sendMessage(); |
| }} |
| }}); |
| }} else {{ |
| // Traditional action form submission |
| const actionForm = document.getElementById('action-form'); |
| if (actionForm) {{ |
| actionForm.addEventListener('submit', (e) => {{ |
| e.preventDefault(); |
| this.submitAction(); |
| }}); |
| }} |
| }} |
| |
| // Reset button |
| document.getElementById('reset-btn').addEventListener('click', () => {{ |
| this.resetEnvironment(); |
| }}); |
| |
| // State button |
| document.getElementById('state-btn').addEventListener('click', () => {{ |
| this.getState(); |
| }}); |
| }} |
| |
| async sendMessage() {{ |
| const messageInput = document.getElementById('message-input'); |
| const roleSelect = document.getElementById('message-role'); |
| const message = messageInput.value.trim(); |
| const role = roleSelect.value; |
| |
| if (!message) {{ |
| return; |
| }} |
| |
| // Add message to chat display immediately |
| this.addMessageToChat(role, message); |
| |
| // Clear input |
| messageInput.value = ''; |
| |
| try {{ |
| // Send message to server to convert to action and step |
| const response = await fetch('/web/step', {{ |
| method: 'POST', |
| headers: {{ 'Content-Type': 'application/json' }}, |
| body: JSON.stringify({{ |
| message: {{ |
| role: role, |
| content: message |
| }} |
| }}) |
| }}); |
| |
| if (!response.ok) {{ |
| throw new Error(`HTTP error! status: ${{response.status}}`); |
| }} |
| |
| const result = await response.json(); |
| console.log('Message sent:', result); |
| }} catch (error) {{ |
| console.error('Error sending message:', error); |
| alert('Error sending message: ' + error.message); |
| }} |
| }} |
| |
| addMessageToChat(role, content) {{ |
| const chatMessages = document.getElementById('chat-messages'); |
| const messageDiv = document.createElement('div'); |
| messageDiv.className = `chat-message ${{role}}`; |
| |
| messageDiv.innerHTML = ` |
| <div class="message-role">${{role.charAt(0).toUpperCase() + role.slice(1)}}</div> |
| <div class="message-content">${{content}}</div> |
| `; |
| |
| chatMessages.appendChild(messageDiv); |
| chatMessages.scrollTop = chatMessages.scrollHeight; |
| }} |
| |
| async submitAction() {{ |
| const formData = new FormData(document.getElementById('action-form')); |
| const action = {{}}; |
| |
| // Collect form data |
| for (const [key, value] of formData.entries()) {{ |
| if (value !== '') {{ |
| // Handle tensor fields (tokens) - convert comma-separated string to array |
| if (key === 'tokens') {{ |
| try {{ |
| action[key] = value.split(',').map(x => parseInt(x.trim())).filter(x => !isNaN(x)); |
| }} catch (e) {{ |
| console.error('Error parsing tokens:', e); |
| action[key] = []; |
| }} |
| }} else {{ |
| action[key] = value; |
| }} |
| }} |
| }} |
| |
| try {{ |
| const response = await fetch('/web/step', {{ |
| method: 'POST', |
| headers: {{ 'Content-Type': 'application/json' }}, |
| body: JSON.stringify({{ action }}) |
| }}); |
| |
| if (!response.ok) {{ |
| throw new Error(`HTTP error! status: ${{response.status}}`); |
| }} |
| |
| const result = await response.json(); |
| console.log('Step result:', result); |
| }} catch (error) {{ |
| console.error('Error submitting action:', error); |
| alert('Error submitting action: ' + error.message); |
| }} |
| }} |
| |
| async resetEnvironment() {{ |
| try {{ |
| const response = await fetch('/web/reset', {{ |
| method: 'POST', |
| headers: {{ 'Content-Type': 'application/json' }} |
| }}); |
| |
| if (!response.ok) {{ |
| throw new Error(`HTTP error! status: ${{response.status}}`); |
| }} |
| |
| const result = await response.json(); |
| console.log('Reset result:', result); |
| }} catch (error) {{ |
| console.error('Error resetting environment:', error); |
| alert('Error resetting environment: ' + error.message); |
| }} |
| }} |
| |
| async getState() {{ |
| try {{ |
| const response = await fetch('/web/state'); |
| const state = await response.json(); |
| console.log('Current state:', state); |
| alert('Current state: ' + JSON.stringify(state, null, 2)); |
| }} catch (error) {{ |
| console.error('Error getting state:', error); |
| alert('Error getting state: ' + error.message); |
| }} |
| }} |
| |
| updateConnectionStatus(connected) {{ |
| const indicator = document.getElementById('connection-status'); |
| if (connected) {{ |
| indicator.className = 'status-indicator status-connected'; |
| }} else {{ |
| indicator.className = 'status-indicator status-disconnected'; |
| }} |
| }} |
| |
| updateUI(episodeState) {{ |
| // Check if this is a chat environment |
| const isChatEnv = document.getElementById('chat-messages') !== null; |
| |
| // Update current state |
| document.getElementById('env-status').textContent = |
| episodeState.is_reset ? 'Reset' : 'Running'; |
| document.getElementById('episode-id').textContent = |
| episodeState.episode_id || '-'; |
| document.getElementById('step-count').textContent = |
| episodeState.step_count.toString(); |
| |
| if (isChatEnv) {{ |
| // Update chat interface |
| this.updateChatInterface(episodeState); |
| }} else {{ |
| // Update traditional observation display |
| const observationDiv = document.getElementById('current-observation'); |
| if (episodeState.current_observation) {{ |
| observationDiv.textContent = JSON.stringify( |
| episodeState.current_observation, null, 2 |
| ); |
| }} else {{ |
| observationDiv.textContent = 'No observation yet'; |
| }} |
| }} |
| |
| // Update action logs |
| const logsDiv = document.getElementById('action-logs'); |
| if (episodeState.action_logs.length === 0) {{ |
| logsDiv.innerHTML = 'No actions taken yet'; |
| }} else {{ |
| logsDiv.innerHTML = episodeState.action_logs.map(log => ` |
| <div class="log-entry"> |
| <div class="log-timestamp">${{log.timestamp}} (Step ${{log.step_count}})</div> |
| <div class="log-action">Action: ${{JSON.stringify(log.action, null, 2)}}</div> |
| <div class="log-observation">Observation: ${{JSON.stringify(log.observation, null, 2)}}</div> |
| <div> |
| <span class="log-reward">Reward: ${{log.reward !== null ? log.reward : 'None'}}</span> |
| ${{log.done ? '<span class="log-done">DONE</span>' : ''}} |
| </div> |
| </div> |
| `).join(''); |
| }} |
| }} |
| |
| updateChatInterface(episodeState) {{ |
| const chatMessages = document.getElementById('chat-messages'); |
| if (!chatMessages) return; |
| |
| // Clear existing messages (except system message) |
| const systemMessage = chatMessages.querySelector('.chat-message.system'); |
| chatMessages.innerHTML = ''; |
| if (systemMessage) {{ |
| chatMessages.appendChild(systemMessage); |
| }} |
| |
| // Add messages from current observation |
| if (episodeState.current_observation && episodeState.current_observation.messages) {{ |
| episodeState.current_observation.messages.forEach(msg => {{ |
| this.addMessageToChat(msg.role, msg.content); |
| }}); |
| }} |
| }} |
| }} |
| |
| // Initialize the web interface when the page loads |
| document.addEventListener('DOMContentLoaded', () => {{ |
| new OpenEnvWebInterface(); |
| }}); |
| </script> |
| </body> |
| </html> |
| """.replace('{_generate_action_form_fields(action_fields)}', _generate_action_form_fields(action_fields)) |
|
|
|
|
| def _generate_instructions_section(metadata: Optional[EnvironmentMetadata]) -> str: |
| """Generate the instructions section with environment documentation.""" |
| if not metadata or not metadata.readme_content: |
| return '' |
| |
| |
| import re |
| html_content = _markdown_to_html(metadata.readme_content) |
| |
| return f''' |
| <!-- Instructions Section --> |
| <div class="instructions-section"> |
| <div class="instructions-header"> |
| <h3 class="instructions-title">{metadata.name}</h3> |
| <button class="instructions-toggle" id="instructions-toggle">Show Instructions</button> |
| </div> |
| <div class="instructions-content" id="instructions-content"> |
| <div class="instructions-readme"> |
| {html_content} |
| </div> |
| </div> |
| </div> |
| ''' |
|
|
|
|
| def _extract_action_fields(action_cls: Type[Action]) -> List[Dict[str, Any]]: |
| """Extract enhanced field metadata from Action class for form generation.""" |
| import typing |
| from typing import get_origin, get_args |
| |
| action_fields = [] |
| if not hasattr(action_cls, '__dataclass_fields__'): |
| return action_fields |
| |
| for field_name, field_info in action_cls.__dataclass_fields__.items(): |
| if field_name == 'metadata': |
| continue |
| |
| field_type = field_info.type |
| field_metadata = _extract_field_metadata(field_name, field_info) |
| |
| |
| input_type = _determine_input_type(field_type) |
| |
| |
| is_required = field_info.default is field_info.default_factory |
| |
| action_fields.append({ |
| 'name': field_name, |
| 'type': input_type, |
| 'required': is_required, |
| 'description': field_metadata.get('description', ''), |
| 'default_value': field_metadata.get('default_value'), |
| 'choices': field_metadata.get('choices', []), |
| 'min_value': field_metadata.get('min_value'), |
| 'max_value': field_metadata.get('max_value'), |
| 'placeholder': field_metadata.get('placeholder', ''), |
| 'help_text': field_metadata.get('help_text', ''), |
| }) |
| |
| return action_fields |
|
|
|
|
| def _extract_field_metadata(field_name: str, field_info) -> Dict[str, Any]: |
| """Extract metadata from dataclass field including docstring and type hints.""" |
| import typing |
| from typing import get_origin, get_args, Literal, Union, Optional |
| |
| metadata = {} |
| |
| |
| if hasattr(field_info, 'metadata') and field_info.metadata: |
| |
| for meta in field_info.metadata: |
| if isinstance(meta, dict): |
| metadata.update(meta) |
| |
| |
| field_type = field_info.type |
| origin = get_origin(field_type) |
| |
| |
| if origin is Literal: |
| args = get_args(field_type) |
| metadata['choices'] = list(args) |
| |
| |
| if origin is Union: |
| args = get_args(field_type) |
| if len(args) == 2 and type(None) in args: |
| |
| non_none_type = args[0] if args[1] is type(None) else args[1] |
| metadata['optional'] = True |
| |
| if get_origin(non_none_type) is Literal: |
| metadata['choices'] = list(get_args(non_none_type)) |
| else: |
| |
| metadata['choices'] = [str(arg) for arg in args if arg is not type(None)] |
| |
| |
| if field_type in (int, float): |
| |
| if 'count' in field_name.lower() or 'num' in field_name.lower(): |
| metadata['min_value'] = 0 |
| if 'id' in field_name.lower(): |
| metadata['min_value'] = 0 |
| |
| |
| if 'message' in field_name.lower(): |
| metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' |
| elif 'code' in field_name.lower(): |
| metadata['placeholder'] = 'Enter Python code here...' |
| elif 'tokens' in field_name.lower(): |
| metadata['placeholder'] = 'Enter comma-separated token IDs (e.g., 1,2,3,4,5)' |
| else: |
| metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' |
| |
| |
| if 'action_id' in field_name.lower(): |
| metadata['help_text'] = 'The action ID to execute in the environment' |
| elif 'game_name' in field_name.lower(): |
| metadata['help_text'] = 'Name of the game or environment' |
| elif 'tokens' in field_name.lower(): |
| metadata['help_text'] = 'Token IDs as a comma-separated list of integers' |
| elif 'code' in field_name.lower(): |
| metadata['help_text'] = 'Python code to execute in the environment' |
| elif 'message' in field_name.lower(): |
| metadata['help_text'] = 'Text message to send' |
| |
| return metadata |
|
|
|
|
| def _determine_input_type(field_type) -> str: |
| """Determine the appropriate HTML input type for a field type.""" |
| import typing |
| from typing import get_origin, get_args, Literal, Union |
| |
| |
| if field_type == str: |
| return "text" |
| elif field_type == int: |
| return "number" |
| elif field_type == float: |
| return "number" |
| elif field_type == bool: |
| return "checkbox" |
| |
| |
| origin = get_origin(field_type) |
| |
| if origin is Literal: |
| return "select" |
| elif origin is Union: |
| args = get_args(field_type) |
| if len(args) == 2 and type(None) in args: |
| |
| non_none_type = args[0] if args[1] is type(None) else args[1] |
| return _determine_input_type(non_none_type) |
| elif all(isinstance(arg, str) for arg in args if arg is not type(None)): |
| return "select" |
| else: |
| return "text" |
| elif hasattr(field_type, '__name__') and 'Tensor' in field_type.__name__: |
| return "tensor" |
| else: |
| return "text" |
|
|
|
|
| def _markdown_to_html(markdown: str) -> str: |
| """Convert basic markdown to HTML for README display.""" |
| import html |
| import re |
| |
| |
| html_content = html.escape(markdown) |
| |
| |
| html_content = re.sub(r'^# (.*?)$', r'<h1>\1</h1>', html_content, flags=re.MULTILINE) |
| html_content = re.sub(r'^## (.*?)$', r'<h2>\1</h2>', html_content, flags=re.MULTILINE) |
| html_content = re.sub(r'^### (.*?)$', r'<h3>\1</h3>', html_content, flags=re.MULTILINE) |
| |
| |
| html_content = re.sub(r'```(.*?)\n(.*?)\n```', r'<pre><code>\2</code></pre>', html_content, flags=re.DOTALL) |
| html_content = re.sub(r'`([^`]+)`', r'<code>\1</code>', html_content) |
| |
| |
| html_content = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html_content) |
| html_content = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html_content) |
| |
| |
| html_content = re.sub(r'^- (.*?)$', r'<li>\1</li>', html_content, flags=re.MULTILINE) |
| html_content = re.sub(r'(<li>.*</li>)', r'<ul>\1</ul>', html_content, flags=re.DOTALL) |
| |
| |
| html_content = html_content.replace('\n', '<br>') |
| |
| return html_content |
|
|
|
|
| def _generate_action_interface(action_fields: List[Dict[str, Any]], is_chat_env: bool) -> str: |
| """Generate either a chat interface or action form based on environment type.""" |
| if is_chat_env: |
| return _generate_chat_interface() |
| else: |
| return _generate_action_form(action_fields) |
|
|
| def _generate_chat_interface() -> str: |
| """Generate a chat-style interface for chat environments.""" |
| return ''' |
| <!-- Chat Interface --> |
| <div class="chat-interface"> |
| <h3>Chat Interface</h3> |
| <div class="chat-messages" id="chat-messages"> |
| <div class="chat-message system"> |
| <div class="message-role">System</div> |
| <div class="message-content">Chat environment ready. Send a message to start the conversation.</div> |
| </div> |
| </div> |
| <div class="chat-input-container"> |
| <div class="role-selector"> |
| <label for="message-role">Role:</label> |
| <select id="message-role"> |
| <option value="user">User</option> |
| <option value="assistant">Assistant</option> |
| </select> |
| </div> |
| <div class="message-input"> |
| <textarea id="message-input" placeholder="Type your message here..." rows="3"></textarea> |
| <button class="btn" id="send-message-btn">Send Message</button> |
| </div> |
| </div> |
| </div> |
| ''' |
|
|
| def _generate_action_form(action_fields: List[Dict[str, Any]]) -> str: |
| """Generate a traditional action form for non-chat environments.""" |
| return f''' |
| <!-- Action Form --> |
| <div class="action-form"> |
| <h3>Take Action</h3> |
| <form id="action-form"> |
| {_generate_action_form_fields(action_fields)} |
| <button type="submit" class="btn" id="step-btn">Step</button> |
| </form> |
| </div> |
| ''' |
|
|
| def _generate_action_form_fields(action_fields: List[Dict[str, Any]]) -> str: |
| """Generate HTML form fields for action input with enhanced metadata.""" |
| if not action_fields: |
| return '<p>No action fields available</p>' |
| |
| fields_html = [] |
| for field in action_fields: |
| field_html = _generate_single_field(field) |
| fields_html.append(field_html) |
| |
| return '\n'.join(fields_html) |
|
|
|
|
| def _generate_single_field(field: Dict[str, Any]) -> str: |
| """Generate HTML for a single form field with enhanced metadata.""" |
| field_name = field['name'] |
| field_type = field['type'] |
| required = field['required'] |
| placeholder = field.get('placeholder', '') |
| help_text = field.get('help_text', '') |
| choices = field.get('choices', []) |
| min_value = field.get('min_value') |
| max_value = field.get('max_value') |
| default_value = field.get('default_value') |
| |
| |
| label_text = field_name.replace('_', ' ').title() |
| if required: |
| label_text += ' <span style="color: red;">*</span>' |
| |
| |
| input_attrs = [] |
| if required: |
| input_attrs.append('required') |
| if placeholder: |
| input_attrs.append(f'placeholder="{placeholder}"') |
| if min_value is not None: |
| input_attrs.append(f'min="{min_value}"') |
| if max_value is not None: |
| input_attrs.append(f'max="{max_value}"') |
| if default_value is not None: |
| input_attrs.append(f'value="{default_value}"') |
| |
| attrs_str = ' '.join(input_attrs) |
| |
| if field_type == 'checkbox': |
| return f''' |
| <div class="form-group"> |
| <label> |
| <input type="checkbox" name="{field_name}" value="true" {attrs_str}> |
| {label_text} |
| </label> |
| {f'<small class="help-text">{help_text}</small>' if help_text else ''} |
| </div> |
| ''' |
| |
| elif field_type == 'select': |
| options_html = [] |
| if not required: |
| options_html.append(f'<option value="">-- Select {label_text} --</option>') |
| |
| for choice in choices: |
| selected = 'selected' if str(choice) == str(default_value) else '' |
| options_html.append(f'<option value="{choice}" {selected}>{choice}</option>') |
| |
| return f''' |
| <div class="form-group"> |
| <label for="{field_name}">{label_text}:</label> |
| <select name="{field_name}" id="{field_name}" {attrs_str}> |
| {''.join(options_html)} |
| </select> |
| {f'<small class="help-text">{help_text}</small>' if help_text else ''} |
| </div> |
| ''' |
| |
| elif field_type == 'tensor': |
| return f''' |
| <div class="form-group"> |
| <label for="{field_name}">{label_text} (comma-separated integers):</label> |
| <input type="text" name="{field_name}" id="{field_name}" {attrs_str}> |
| <small class="help-text">{help_text or 'Enter token IDs as comma-separated integers (e.g., 1,2,3,4,5)'}</small> |
| </div> |
| ''' |
| |
| elif field_type == 'text' and ('message' in field_name.lower() or 'code' in field_name.lower()): |
| return f''' |
| <div class="form-group"> |
| <label for="{field_name}">{label_text}:</label> |
| <textarea name="{field_name}" id="{field_name}" rows="3" {attrs_str}></textarea> |
| {f'<small class="help-text">{help_text}</small>' if help_text else ''} |
| </div> |
| ''' |
| |
| else: |
| return f''' |
| <div class="form-group"> |
| <label for="{field_name}">{label_text}:</label> |
| <input type="{field_type}" name="{field_name}" id="{field_name}" {attrs_str}> |
| {f'<small class="help-text">{help_text}</small>' if help_text else ''} |
| </div> |
| ''' |
|
|